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
data/PLAN.md
ADDED
|
@@ -0,0 +1,2927 @@
|
|
|
1
|
+
# pure_greeks v0.1.0 Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a pure-Ruby gem (`pure_greeks`) that computes options Greeks (delta, gamma, theta, vega, rho), prices, and implied volatility for vanilla European and American options — with no Python or QuantLib dependency. Validate against a golden dataset of historical QuantLib outputs from the Tenor codebase.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Object-oriented public API (`PureGreeks::Option`) backed by a pluggable engine architecture with a three-tier fallback chain (CRR Binomial American → Black-Scholes European → Intrinsic). Engines are stateless calculators; the `Option` class orchestrates engine selection and exposes lazy, cached Greek/price accessors. An IV solver inverts the pricing function via Brent's method.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby 3.2+, RSpec, `distribution` gem (normal CDF/PDF), `bigdecimal` (stdlib, for IV solver bounds), GitHub Actions for CI. No native extensions in v0.1.0 — performance pass at the end determines whether v0.2 needs them.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Origin & Background
|
|
14
|
+
|
|
15
|
+
This gem extracts and re-implements the Greeks calculation pipeline from the Tenor application (`bin/calculate_greeks.py`), which currently uses QuantLib via a Python subprocess. Rewriting in pure Ruby eliminates the cross-language boundary, makes the math portable for other Ruby projects, and removes the system-level QuantLib dependency.
|
|
16
|
+
|
|
17
|
+
The reference implementation in Tenor uses three QuantLib engines in fallback order:
|
|
18
|
+
|
|
19
|
+
1. **CRR Binomial American (200-step)** — handles ~83% of options
|
|
20
|
+
2. **Black-Scholes European (analytic)** — fallback for extreme IV that breaks the binomial tree
|
|
21
|
+
3. **Intrinsic value** — last resort for zero/negative IV
|
|
22
|
+
|
|
23
|
+
This plan reproduces that pipeline using stdlib + `distribution` gem, validates numerical agreement against the Tenor production database, and ships a clean OO API.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Public API Design (target shape)
|
|
28
|
+
|
|
29
|
+
The gem must expose this API. All later tasks must conform.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "pure_greeks"
|
|
33
|
+
|
|
34
|
+
# Compute Greeks given IV
|
|
35
|
+
option = PureGreeks::Option.new(
|
|
36
|
+
exercise_style: :american, # or :european
|
|
37
|
+
type: :call, # or :put
|
|
38
|
+
strike: 150.0,
|
|
39
|
+
expiration: Date.new(2026, 6, 19),
|
|
40
|
+
underlying_price: 148.5,
|
|
41
|
+
implied_volatility: 0.35,
|
|
42
|
+
risk_free_rate: 0.05,
|
|
43
|
+
dividend_yield: 0.0,
|
|
44
|
+
valuation_date: Date.new(2026, 4, 26)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
option.price # => Float
|
|
48
|
+
option.delta # => Float
|
|
49
|
+
option.gamma # => Float
|
|
50
|
+
option.theta # => Float (per calendar day)
|
|
51
|
+
option.vega # => Float (per 1% vol move)
|
|
52
|
+
option.rho # => Float (per 1% rate move)
|
|
53
|
+
option.greeks # => PureGreeks::Greeks struct (all five + price + model)
|
|
54
|
+
option.calculation_model # => :crr_binomial_american | :black_scholes_european | :intrinsic
|
|
55
|
+
|
|
56
|
+
# Solve for implied volatility given a market price
|
|
57
|
+
option = PureGreeks::Option.new(
|
|
58
|
+
exercise_style: :european,
|
|
59
|
+
type: :call,
|
|
60
|
+
strike: 150.0,
|
|
61
|
+
expiration: Date.new(2026, 6, 19),
|
|
62
|
+
underlying_price: 148.5,
|
|
63
|
+
market_price: 5.20,
|
|
64
|
+
risk_free_rate: 0.05,
|
|
65
|
+
dividend_yield: 0.0,
|
|
66
|
+
valuation_date: Date.new(2026, 4, 26)
|
|
67
|
+
)
|
|
68
|
+
option.implied_volatility # => Float (solved via Brent's method)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## File Structure
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
pure_greeks/
|
|
77
|
+
├── pure_greeks.gemspec
|
|
78
|
+
├── Gemfile
|
|
79
|
+
├── Rakefile
|
|
80
|
+
├── README.md
|
|
81
|
+
├── CHANGELOG.md
|
|
82
|
+
├── LICENSE.txt
|
|
83
|
+
├── .rspec
|
|
84
|
+
├── .rubocop.yml
|
|
85
|
+
├── .github/workflows/ci.yml
|
|
86
|
+
├── lib/
|
|
87
|
+
│ ├── pure_greeks.rb # Top-level require + version
|
|
88
|
+
│ ├── pure_greeks/
|
|
89
|
+
│ │ ├── version.rb # VERSION constant
|
|
90
|
+
│ │ ├── option.rb # Public OO entry point
|
|
91
|
+
│ │ ├── greeks.rb # Greeks value object (Data)
|
|
92
|
+
│ │ ├── errors.rb # Custom error classes
|
|
93
|
+
│ │ ├── math/
|
|
94
|
+
│ │ │ └── normal.rb # Normal CDF/PDF (wraps `distribution`)
|
|
95
|
+
│ │ ├── engines/
|
|
96
|
+
│ │ │ ├── base.rb # Abstract engine interface
|
|
97
|
+
│ │ │ ├── black_scholes_european.rb # Closed-form analytic engine
|
|
98
|
+
│ │ │ ├── crr_binomial_american.rb # 200-step binomial tree engine
|
|
99
|
+
│ │ │ ├── intrinsic.rb # Zero-IV fallback engine
|
|
100
|
+
│ │ │ └── fallback_chain.rb # Tier orchestrator
|
|
101
|
+
│ │ └── implied_volatility/
|
|
102
|
+
│ │ └── brent_solver.rb # Brent's method root finder
|
|
103
|
+
├── spec/
|
|
104
|
+
│ ├── spec_helper.rb
|
|
105
|
+
│ ├── pure_greeks_spec.rb
|
|
106
|
+
│ ├── option_spec.rb
|
|
107
|
+
│ ├── greeks_spec.rb
|
|
108
|
+
│ ├── math/
|
|
109
|
+
│ │ └── normal_spec.rb
|
|
110
|
+
│ ├── engines/
|
|
111
|
+
│ │ ├── black_scholes_european_spec.rb
|
|
112
|
+
│ │ ├── crr_binomial_american_spec.rb
|
|
113
|
+
│ │ ├── intrinsic_spec.rb
|
|
114
|
+
│ │ └── fallback_chain_spec.rb
|
|
115
|
+
│ ├── implied_volatility/
|
|
116
|
+
│ │ └── brent_solver_spec.rb
|
|
117
|
+
│ └── regression/
|
|
118
|
+
│ ├── golden_dataset_spec.rb # Compares to Tenor QuantLib outputs
|
|
119
|
+
│ └── fixtures/
|
|
120
|
+
│ └── tenor_golden.json # Exported via tools/golden_dataset_export.rb
|
|
121
|
+
├── bench/
|
|
122
|
+
│ ├── single_option.rb
|
|
123
|
+
│ └── batch.rb
|
|
124
|
+
└── tools/
|
|
125
|
+
└── golden_dataset_export.rb # Pulls from Tenor prod DB via MCP
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Responsibility map:**
|
|
129
|
+
|
|
130
|
+
| File | Responsibility |
|
|
131
|
+
|------|----------------|
|
|
132
|
+
| `option.rb` | Public OO API. Validates inputs, picks engine, exposes lazy Greek/price accessors, runs IV solver when `market_price` given. |
|
|
133
|
+
| `greeks.rb` | Immutable value object holding `delta`, `gamma`, `theta`, `vega`, `rho`, `price`, `model`. |
|
|
134
|
+
| `math/normal.rb` | Wraps `Distribution::Normal.cdf` / `.pdf` behind a stable internal namespace. Single point of change if the dependency is swapped. |
|
|
135
|
+
| `engines/base.rb` | Defines the engine interface: `#calculate(option_data) → Greeks`. |
|
|
136
|
+
| `engines/black_scholes_european.rb` | Closed-form formulas for European option price + Greeks. |
|
|
137
|
+
| `engines/crr_binomial_american.rb` | 200-step Cox-Ross-Rubinstein tree. Backward induction with early-exercise check. Finite-difference vega/rho. |
|
|
138
|
+
| `engines/intrinsic.rb` | Returns intrinsic value + binary delta. Used for zero/negative IV. |
|
|
139
|
+
| `engines/fallback_chain.rb` | Tries engines in order (American → European → Intrinsic), catching numerical failures. |
|
|
140
|
+
| `implied_volatility/brent_solver.rb` | Brent's method root finder. Brackets IV between `[1e-6, 5.0]` and inverts the price function. |
|
|
141
|
+
| `tools/golden_dataset_export.rb` | One-off script that pulls (snapshot inputs, computed Greeks) from Tenor's `options.greeks` table via the MCP `mcp__postgres-prod__query` tool, writes JSON fixture. |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Prerequisites for the Implementing Session
|
|
146
|
+
|
|
147
|
+
The session executing this plan must have:
|
|
148
|
+
|
|
149
|
+
1. **Ruby 3.2+** installed (`ruby -v`)
|
|
150
|
+
2. **Bundler** (`gem install bundler`)
|
|
151
|
+
3. **Tenor MCP access** — specifically, `mcp__postgres-prod__query` must be available to pull the golden dataset in Phase 7. Confirm with `mcp` tool list before starting Phase 7. If unavailable, skip Phase 7 and flag for follow-up.
|
|
152
|
+
|
|
153
|
+
Per Tenor `CLAUDE.md` memory: writes against the production DB require explicit user permission. Phase 7 is **read-only** (`SELECT` only) — the export tool must enforce that.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Phase 0: Project Skeleton
|
|
158
|
+
|
|
159
|
+
### Task 1: Initialize gem skeleton with bundler
|
|
160
|
+
|
|
161
|
+
**Files:**
|
|
162
|
+
- Scaffold via `bundle gem` in a temp dir, then copy results into `~/Code/pure_greeks/` to preserve the existing `.git` and `PLAN.md`.
|
|
163
|
+
|
|
164
|
+
The repo already exists at `~/Code/pure_greeks/` with an initial commit on `main` containing `PLAN.md`. `bundle gem` requires a non-existent target directory, so we scaffold to a temp location and copy files in.
|
|
165
|
+
|
|
166
|
+
- [ ] **Step 1: Scaffold to temp location**
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
cd /tmp
|
|
170
|
+
rm -rf /tmp/pure_greeks_scaffold
|
|
171
|
+
bundle gem pure_greeks_scaffold --test=rspec --linter=rubocop --ci=github --no-mit --no-coc --no-changelog
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Expected: `/tmp/pure_greeks_scaffold/` exists with full gem layout (lib/, spec/, gemspec, Rakefile, .git, etc.).
|
|
175
|
+
|
|
176
|
+
- [ ] **Step 2: Drop the scaffold's git so we don't overwrite ours**
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
rm -rf /tmp/pure_greeks_scaffold/.git
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- [ ] **Step 3: Rename gem internals from `pure_greeks_scaffold` → `pure_greeks`**
|
|
183
|
+
|
|
184
|
+
The scaffold uses the dir name throughout. Rename:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
cd /tmp/pure_greeks_scaffold
|
|
188
|
+
mv pure_greeks_scaffold.gemspec pure_greeks.gemspec
|
|
189
|
+
mv lib/pure_greeks_scaffold lib/pure_greeks
|
|
190
|
+
mv lib/pure_greeks_scaffold.rb lib/pure_greeks.rb
|
|
191
|
+
mv spec/pure_greeks_scaffold_spec.rb spec/pure_greeks_spec.rb
|
|
192
|
+
# Replace identifiers inside files (sed -i '' is BSD/macOS syntax)
|
|
193
|
+
grep -rl 'pure_greeks_scaffold\|PureGreeksScaffold' . | xargs sed -i '' 's/PureGreeksScaffold/PureGreeks/g; s/pure_greeks_scaffold/pure_greeks/g'
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- [ ] **Step 4: Copy scaffold into our repo**
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
cd /tmp/pure_greeks_scaffold
|
|
200
|
+
# Use cp -r with /. to copy hidden dotfiles (.rspec, .rubocop.yml, .github/) too
|
|
201
|
+
cp -r ./. ~/Code/pure_greeks/
|
|
202
|
+
rm -rf /tmp/pure_greeks_scaffold
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 5: Set Ruby version floor**
|
|
206
|
+
|
|
207
|
+
Edit `~/Code/pure_greeks/pure_greeks.gemspec`:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
- [ ] **Step 6: Verify skeleton**
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
cd ~/Code/pure_greeks
|
|
217
|
+
bundle install
|
|
218
|
+
bundle exec rspec
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Expected: bundler installs deps; `rspec` reports `0 examples, 0 failures` (or whatever scaffold-default specs ran — clean either way).
|
|
222
|
+
|
|
223
|
+
- [ ] **Step 7: Commit**
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git add .
|
|
227
|
+
git commit -m "chore: initialize gem skeleton with bundler"
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Task 2: Add runtime + dev dependencies
|
|
231
|
+
|
|
232
|
+
**Files:**
|
|
233
|
+
- Modify: `pure_greeks.gemspec`
|
|
234
|
+
- Modify: `Gemfile`
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 1: Add `distribution` runtime dependency**
|
|
237
|
+
|
|
238
|
+
Edit `pure_greeks.gemspec`, inside the `Gem::Specification.new` block:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
spec.add_dependency "distribution", "~> 0.8"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 2: Install**
|
|
245
|
+
|
|
246
|
+
Run: `bundle install`
|
|
247
|
+
Expected: `distribution` installed.
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 3: Verify the require path works**
|
|
250
|
+
|
|
251
|
+
Run: `bundle exec ruby -e "require 'distribution'; puts Distribution::Normal.cdf(0.0)"`
|
|
252
|
+
Expected: `0.5`
|
|
253
|
+
|
|
254
|
+
- [ ] **Step 4: Commit**
|
|
255
|
+
|
|
256
|
+
```bash
|
|
257
|
+
git add pure_greeks.gemspec Gemfile.lock
|
|
258
|
+
git commit -m "feat: add distribution gem for normal CDF/PDF"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### Task 3: Configure RSpec
|
|
262
|
+
|
|
263
|
+
**Files:**
|
|
264
|
+
- Modify: `.rspec`
|
|
265
|
+
- Modify: `spec/spec_helper.rb`
|
|
266
|
+
|
|
267
|
+
- [ ] **Step 1: Update `.rspec`**
|
|
268
|
+
|
|
269
|
+
Replace contents of `.rspec`:
|
|
270
|
+
|
|
271
|
+
```
|
|
272
|
+
--require spec_helper
|
|
273
|
+
--format documentation
|
|
274
|
+
--color
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
- [ ] **Step 2: Verify spec_helper is sane**
|
|
278
|
+
|
|
279
|
+
Read `spec/spec_helper.rb`. Ensure it requires `pure_greeks`:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
require "pure_greeks"
|
|
283
|
+
|
|
284
|
+
RSpec.configure do |config|
|
|
285
|
+
config.expect_with :rspec do |c|
|
|
286
|
+
c.syntax = :expect
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
- [ ] **Step 3: Run rspec to confirm**
|
|
292
|
+
|
|
293
|
+
Run: `bundle exec rspec`
|
|
294
|
+
Expected: 0 examples, 0 failures.
|
|
295
|
+
|
|
296
|
+
- [ ] **Step 4: Commit**
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
git add .rspec spec/spec_helper.rb
|
|
300
|
+
git commit -m "chore: configure RSpec output and require"
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## Phase 1: Math Foundations
|
|
306
|
+
|
|
307
|
+
### Task 4: Normal distribution wrapper
|
|
308
|
+
|
|
309
|
+
**Files:**
|
|
310
|
+
- Create: `lib/pure_greeks/math/normal.rb`
|
|
311
|
+
- Test: `spec/math/normal_spec.rb`
|
|
312
|
+
|
|
313
|
+
Wraps the `distribution` gem behind an internal namespace. This is the only place the gem touches `Distribution::Normal` directly — if the dep is swapped, only this file changes.
|
|
314
|
+
|
|
315
|
+
- [ ] **Step 1: Write the failing tests**
|
|
316
|
+
|
|
317
|
+
Create `spec/math/normal_spec.rb`:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
require "pure_greeks/math/normal"
|
|
321
|
+
|
|
322
|
+
RSpec.describe PureGreeks::Math::Normal do
|
|
323
|
+
describe ".cdf" do
|
|
324
|
+
it "returns 0.5 at zero" do
|
|
325
|
+
expect(described_class.cdf(0.0)).to be_within(1e-10).of(0.5)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
it "returns ~0.8413 at one std dev" do
|
|
329
|
+
expect(described_class.cdf(1.0)).to be_within(1e-4).of(0.8413)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
it "returns ~0.9772 at two std devs" do
|
|
333
|
+
expect(described_class.cdf(2.0)).to be_within(1e-4).of(0.9772)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it "returns symmetric values around zero" do
|
|
337
|
+
expect(described_class.cdf(-1.5) + described_class.cdf(1.5)).to be_within(1e-10).of(1.0)
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
describe ".pdf" do
|
|
342
|
+
it "returns 1/sqrt(2*pi) at zero" do
|
|
343
|
+
expect(described_class.pdf(0.0)).to be_within(1e-10).of(1.0 / ::Math.sqrt(2 * ::Math::PI))
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it "is symmetric" do
|
|
347
|
+
expect(described_class.pdf(-1.7)).to be_within(1e-10).of(described_class.pdf(1.7))
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
- [ ] **Step 2: Run, expect failure**
|
|
354
|
+
|
|
355
|
+
Run: `bundle exec rspec spec/math/normal_spec.rb`
|
|
356
|
+
Expected: `LoadError` — `pure_greeks/math/normal` does not exist.
|
|
357
|
+
|
|
358
|
+
- [ ] **Step 3: Implement**
|
|
359
|
+
|
|
360
|
+
Create `lib/pure_greeks/math/normal.rb`:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
require "distribution"
|
|
364
|
+
|
|
365
|
+
module PureGreeks
|
|
366
|
+
module Math
|
|
367
|
+
module Normal
|
|
368
|
+
def self.cdf(x)
|
|
369
|
+
Distribution::Normal.cdf(x)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def self.pdf(x)
|
|
373
|
+
Distribution::Normal.pdf(x)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
- [ ] **Step 4: Run, expect pass**
|
|
381
|
+
|
|
382
|
+
Run: `bundle exec rspec spec/math/normal_spec.rb`
|
|
383
|
+
Expected: 6 examples, 0 failures.
|
|
384
|
+
|
|
385
|
+
- [ ] **Step 5: Commit**
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
git add lib/pure_greeks/math/normal.rb spec/math/normal_spec.rb
|
|
389
|
+
git commit -m "feat: add Normal distribution wrapper"
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Task 5: Greeks value object
|
|
393
|
+
|
|
394
|
+
**Files:**
|
|
395
|
+
- Create: `lib/pure_greeks/greeks.rb`
|
|
396
|
+
- Test: `spec/greeks_spec.rb`
|
|
397
|
+
|
|
398
|
+
Immutable `Data` class. Holds the five Greeks plus price plus the engine that produced them.
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 1: Write the failing tests**
|
|
401
|
+
|
|
402
|
+
Create `spec/greeks_spec.rb`:
|
|
403
|
+
|
|
404
|
+
```ruby
|
|
405
|
+
require "pure_greeks/greeks"
|
|
406
|
+
|
|
407
|
+
RSpec.describe PureGreeks::Greeks do
|
|
408
|
+
let(:greeks) do
|
|
409
|
+
described_class.new(
|
|
410
|
+
delta: 0.5,
|
|
411
|
+
gamma: 0.02,
|
|
412
|
+
theta: -0.01,
|
|
413
|
+
vega: 0.15,
|
|
414
|
+
rho: 0.08,
|
|
415
|
+
price: 4.25,
|
|
416
|
+
model: :black_scholes_european
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
it "exposes all six numeric fields" do
|
|
421
|
+
expect(greeks.delta).to eq(0.5)
|
|
422
|
+
expect(greeks.gamma).to eq(0.02)
|
|
423
|
+
expect(greeks.theta).to eq(-0.01)
|
|
424
|
+
expect(greeks.vega).to eq(0.15)
|
|
425
|
+
expect(greeks.rho).to eq(0.08)
|
|
426
|
+
expect(greeks.price).to eq(4.25)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
it "exposes the model symbol" do
|
|
430
|
+
expect(greeks.model).to eq(:black_scholes_european)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
it "is immutable" do
|
|
434
|
+
expect { greeks.delta = 0.7 }.to raise_error(NoMethodError)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
- [ ] **Step 2: Run, expect failure**
|
|
440
|
+
|
|
441
|
+
Run: `bundle exec rspec spec/greeks_spec.rb`
|
|
442
|
+
Expected: LoadError.
|
|
443
|
+
|
|
444
|
+
- [ ] **Step 3: Implement**
|
|
445
|
+
|
|
446
|
+
Create `lib/pure_greeks/greeks.rb`:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
module PureGreeks
|
|
450
|
+
Greeks = Data.define(:delta, :gamma, :theta, :vega, :rho, :price, :model)
|
|
451
|
+
end
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
- [ ] **Step 4: Run, expect pass**
|
|
455
|
+
|
|
456
|
+
Run: `bundle exec rspec spec/greeks_spec.rb`
|
|
457
|
+
Expected: 3 examples, 0 failures.
|
|
458
|
+
|
|
459
|
+
- [ ] **Step 5: Commit**
|
|
460
|
+
|
|
461
|
+
```bash
|
|
462
|
+
git add lib/pure_greeks/greeks.rb spec/greeks_spec.rb
|
|
463
|
+
git commit -m "feat: add Greeks value object"
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Task 6: Custom error classes
|
|
467
|
+
|
|
468
|
+
**Files:**
|
|
469
|
+
- Create: `lib/pure_greeks/errors.rb`
|
|
470
|
+
|
|
471
|
+
- [ ] **Step 1: Implement**
|
|
472
|
+
|
|
473
|
+
Create `lib/pure_greeks/errors.rb`:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
module PureGreeks
|
|
477
|
+
class Error < StandardError; end
|
|
478
|
+
class InvalidInputError < Error; end
|
|
479
|
+
class ExpiredContractError < InvalidInputError; end
|
|
480
|
+
class CalculationError < Error; end
|
|
481
|
+
class IVConvergenceError < CalculationError; end
|
|
482
|
+
end
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- [ ] **Step 2: Commit**
|
|
486
|
+
|
|
487
|
+
```bash
|
|
488
|
+
git add lib/pure_greeks/errors.rb
|
|
489
|
+
git commit -m "feat: add error class hierarchy"
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## Phase 2: Black-Scholes European Engine
|
|
495
|
+
|
|
496
|
+
### Task 7: Black-Scholes pricing — call
|
|
497
|
+
|
|
498
|
+
**Files:**
|
|
499
|
+
- Create: `lib/pure_greeks/engines/black_scholes_european.rb` (incremental — pricing first)
|
|
500
|
+
- Test: `spec/engines/black_scholes_european_spec.rb`
|
|
501
|
+
|
|
502
|
+
**Reference values** (Hull, 11e — Spot=100, Strike=100, T=1.0 yr, r=5%, σ=20%, q=0):
|
|
503
|
+
- Call price = 10.4506
|
|
504
|
+
- Put price = 5.5735
|
|
505
|
+
|
|
506
|
+
**Black-Scholes formulas:**
|
|
507
|
+
|
|
508
|
+
```
|
|
509
|
+
d1 = (ln(S/K) + (r - q + σ²/2)·T) / (σ·√T)
|
|
510
|
+
d2 = d1 − σ·√T
|
|
511
|
+
Call = S·e^(−q·T)·N(d1) − K·e^(−r·T)·N(d2)
|
|
512
|
+
Put = K·e^(−r·T)·N(−d2) − S·e^(−q·T)·N(−d1)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- [ ] **Step 1: Write failing test for call price**
|
|
516
|
+
|
|
517
|
+
Create `spec/engines/black_scholes_european_spec.rb`:
|
|
518
|
+
|
|
519
|
+
```ruby
|
|
520
|
+
require "pure_greeks/engines/black_scholes_european"
|
|
521
|
+
|
|
522
|
+
RSpec.describe PureGreeks::Engines::BlackScholesEuropean do
|
|
523
|
+
let(:hull_inputs) do
|
|
524
|
+
{
|
|
525
|
+
type: :call,
|
|
526
|
+
strike: 100.0,
|
|
527
|
+
underlying_price: 100.0,
|
|
528
|
+
time_to_expiry: 1.0,
|
|
529
|
+
implied_volatility: 0.20,
|
|
530
|
+
risk_free_rate: 0.05,
|
|
531
|
+
dividend_yield: 0.0
|
|
532
|
+
}
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
describe ".price" do
|
|
536
|
+
it "matches Hull reference for at-the-money call" do
|
|
537
|
+
expect(described_class.price(**hull_inputs)).to be_within(1e-3).of(10.4506)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
- [ ] **Step 2: Run, expect failure**
|
|
544
|
+
|
|
545
|
+
Run: `bundle exec rspec spec/engines/black_scholes_european_spec.rb`
|
|
546
|
+
Expected: LoadError.
|
|
547
|
+
|
|
548
|
+
- [ ] **Step 3: Implement**
|
|
549
|
+
|
|
550
|
+
Create `lib/pure_greeks/engines/black_scholes_european.rb`:
|
|
551
|
+
|
|
552
|
+
```ruby
|
|
553
|
+
require "pure_greeks/math/normal"
|
|
554
|
+
|
|
555
|
+
module PureGreeks
|
|
556
|
+
module Engines
|
|
557
|
+
module BlackScholesEuropean
|
|
558
|
+
module_function
|
|
559
|
+
|
|
560
|
+
def price(type:, strike:, underlying_price:, time_to_expiry:, implied_volatility:, risk_free_rate:, dividend_yield:)
|
|
561
|
+
d1, d2 = d1_d2(strike, underlying_price, time_to_expiry, implied_volatility, risk_free_rate, dividend_yield)
|
|
562
|
+
s_disc = underlying_price * ::Math.exp(-dividend_yield * time_to_expiry)
|
|
563
|
+
k_disc = strike * ::Math.exp(-risk_free_rate * time_to_expiry)
|
|
564
|
+
|
|
565
|
+
if type == :call
|
|
566
|
+
s_disc * Math::Normal.cdf(d1) - k_disc * Math::Normal.cdf(d2)
|
|
567
|
+
else
|
|
568
|
+
k_disc * Math::Normal.cdf(-d2) - s_disc * Math::Normal.cdf(-d1)
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def d1_d2(strike, spot, t, sigma, r, q)
|
|
573
|
+
sqrt_t = ::Math.sqrt(t)
|
|
574
|
+
d1 = (::Math.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
575
|
+
d2 = d1 - sigma * sqrt_t
|
|
576
|
+
[d1, d2]
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
- [ ] **Step 4: Run, expect pass**
|
|
584
|
+
|
|
585
|
+
Run: `bundle exec rspec spec/engines/black_scholes_european_spec.rb`
|
|
586
|
+
Expected: 1 example, 0 failures.
|
|
587
|
+
|
|
588
|
+
- [ ] **Step 5: Commit**
|
|
589
|
+
|
|
590
|
+
```bash
|
|
591
|
+
git add lib/pure_greeks/engines/black_scholes_european.rb spec/engines/black_scholes_european_spec.rb
|
|
592
|
+
git commit -m "feat: add Black-Scholes European call pricing"
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Task 8: Black-Scholes pricing — put + put-call parity
|
|
596
|
+
|
|
597
|
+
**Files:**
|
|
598
|
+
- Modify: `spec/engines/black_scholes_european_spec.rb`
|
|
599
|
+
|
|
600
|
+
- [ ] **Step 1: Add put price test**
|
|
601
|
+
|
|
602
|
+
Append to `spec/engines/black_scholes_european_spec.rb`, inside `describe ".price"`:
|
|
603
|
+
|
|
604
|
+
```ruby
|
|
605
|
+
it "matches Hull reference for at-the-money put" do
|
|
606
|
+
expect(described_class.price(**hull_inputs.merge(type: :put))).to be_within(1e-3).of(5.5735)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
it "satisfies put-call parity" do
|
|
610
|
+
call = described_class.price(**hull_inputs)
|
|
611
|
+
put = described_class.price(**hull_inputs.merge(type: :put))
|
|
612
|
+
s, k, r, q, t = 100.0, 100.0, 0.05, 0.0, 1.0
|
|
613
|
+
parity = call - put - (s * ::Math.exp(-q * t) - k * ::Math.exp(-r * t))
|
|
614
|
+
expect(parity).to be_within(1e-10).of(0.0)
|
|
615
|
+
end
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 2: Run, expect pass (put already implemented in Task 7)**
|
|
619
|
+
|
|
620
|
+
Run: `bundle exec rspec spec/engines/black_scholes_european_spec.rb`
|
|
621
|
+
Expected: 3 examples, 0 failures.
|
|
622
|
+
|
|
623
|
+
- [ ] **Step 3: Commit**
|
|
624
|
+
|
|
625
|
+
```bash
|
|
626
|
+
git add spec/engines/black_scholes_european_spec.rb
|
|
627
|
+
git commit -m "test: verify Black-Scholes put pricing and put-call parity"
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### Task 9: Black-Scholes Greeks (delta, gamma, theta, vega, rho)
|
|
631
|
+
|
|
632
|
+
**Files:**
|
|
633
|
+
- Modify: `lib/pure_greeks/engines/black_scholes_european.rb`
|
|
634
|
+
- Modify: `spec/engines/black_scholes_european_spec.rb`
|
|
635
|
+
|
|
636
|
+
**Reference Greeks** (Hull params, ATM call):
|
|
637
|
+
- Delta = N(d1) = 0.6368
|
|
638
|
+
- Gamma = N'(d1)/(S·σ·√T) = 0.01876
|
|
639
|
+
- Theta (per year) = −6.4140 → per day = −0.01757
|
|
640
|
+
- Vega (per 1.0 vol move) = 37.524 → per 1% = 0.37524
|
|
641
|
+
- Rho (per 1.0 rate move) = 53.232 → per 1% = 0.53232
|
|
642
|
+
|
|
643
|
+
**Greek formulas:**
|
|
644
|
+
|
|
645
|
+
```
|
|
646
|
+
Δ_call = e^(−q·T)·N(d1)
|
|
647
|
+
Δ_put = −e^(−q·T)·N(−d1)
|
|
648
|
+
Γ = e^(−q·T)·φ(d1)/(S·σ·√T) (same call/put)
|
|
649
|
+
Θ_call = −S·φ(d1)·σ·e^(−q·T)/(2·√T) − r·K·e^(−r·T)·N(d2) + q·S·e^(−q·T)·N(d1)
|
|
650
|
+
Θ_put = −S·φ(d1)·σ·e^(−q·T)/(2·√T) + r·K·e^(−r·T)·N(−d2) − q·S·e^(−q·T)·N(−d1)
|
|
651
|
+
ν = S·e^(−q·T)·φ(d1)·√T (same call/put; per 1.0 vol)
|
|
652
|
+
ρ_call = K·T·e^(−r·T)·N(d2) (per 1.0 rate)
|
|
653
|
+
ρ_put = −K·T·e^(−r·T)·N(−d2) (per 1.0 rate)
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
The engine returns theta scaled per **calendar day** (theta_per_year / 365), vega per **1% move** (vega / 100), rho per **1% move** (rho / 100) — matching the Tenor reference implementation.
|
|
657
|
+
|
|
658
|
+
- [ ] **Step 1: Write failing test for full Greeks output**
|
|
659
|
+
|
|
660
|
+
Append to `spec/engines/black_scholes_european_spec.rb`:
|
|
661
|
+
|
|
662
|
+
```ruby
|
|
663
|
+
describe ".calculate" do
|
|
664
|
+
it "returns Greeks struct matching Hull reference for ATM call" do
|
|
665
|
+
g = described_class.calculate(**hull_inputs)
|
|
666
|
+
expect(g.price).to be_within(1e-3).of(10.4506)
|
|
667
|
+
expect(g.delta).to be_within(1e-4).of(0.6368)
|
|
668
|
+
expect(g.gamma).to be_within(1e-5).of(0.01876)
|
|
669
|
+
expect(g.theta).to be_within(1e-4).of(-0.01757)
|
|
670
|
+
expect(g.vega).to be_within(1e-4).of(0.37524)
|
|
671
|
+
expect(g.rho).to be_within(1e-3).of(0.53232)
|
|
672
|
+
expect(g.model).to eq(:black_scholes_european)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
it "returns negative delta for put" do
|
|
676
|
+
g = described_class.calculate(**hull_inputs.merge(type: :put))
|
|
677
|
+
expect(g.delta).to be_within(1e-4).of(-0.3632)
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 2: Run, expect failure**
|
|
683
|
+
|
|
684
|
+
Run: `bundle exec rspec spec/engines/black_scholes_european_spec.rb`
|
|
685
|
+
Expected: NoMethodError on `.calculate`.
|
|
686
|
+
|
|
687
|
+
- [ ] **Step 3: Implement `.calculate`**
|
|
688
|
+
|
|
689
|
+
Replace `lib/pure_greeks/engines/black_scholes_european.rb` contents:
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
require "pure_greeks/math/normal"
|
|
693
|
+
require "pure_greeks/greeks"
|
|
694
|
+
|
|
695
|
+
module PureGreeks
|
|
696
|
+
module Engines
|
|
697
|
+
module BlackScholesEuropean
|
|
698
|
+
module_function
|
|
699
|
+
|
|
700
|
+
def calculate(type:, strike:, underlying_price:, time_to_expiry:, implied_volatility:, risk_free_rate:, dividend_yield:)
|
|
701
|
+
d1, d2 = d1_d2(strike, underlying_price, time_to_expiry, implied_volatility, risk_free_rate, dividend_yield)
|
|
702
|
+
sqrt_t = ::Math.sqrt(time_to_expiry)
|
|
703
|
+
s_disc = underlying_price * ::Math.exp(-dividend_yield * time_to_expiry)
|
|
704
|
+
k_disc = strike * ::Math.exp(-risk_free_rate * time_to_expiry)
|
|
705
|
+
nd1 = Math::Normal.cdf(d1)
|
|
706
|
+
nd2 = Math::Normal.cdf(d2)
|
|
707
|
+
n_neg_d1 = Math::Normal.cdf(-d1)
|
|
708
|
+
n_neg_d2 = Math::Normal.cdf(-d2)
|
|
709
|
+
pdf_d1 = Math::Normal.pdf(d1)
|
|
710
|
+
|
|
711
|
+
price = type == :call ? s_disc * nd1 - k_disc * nd2 : k_disc * n_neg_d2 - s_disc * n_neg_d1
|
|
712
|
+
delta = type == :call ? ::Math.exp(-dividend_yield * time_to_expiry) * nd1 : -::Math.exp(-dividend_yield * time_to_expiry) * n_neg_d1
|
|
713
|
+
gamma = ::Math.exp(-dividend_yield * time_to_expiry) * pdf_d1 / (underlying_price * implied_volatility * sqrt_t)
|
|
714
|
+
|
|
715
|
+
theta_year =
|
|
716
|
+
if type == :call
|
|
717
|
+
-s_disc * pdf_d1 * implied_volatility / (2 * sqrt_t) -
|
|
718
|
+
risk_free_rate * k_disc * nd2 +
|
|
719
|
+
dividend_yield * s_disc * nd1
|
|
720
|
+
else
|
|
721
|
+
-s_disc * pdf_d1 * implied_volatility / (2 * sqrt_t) +
|
|
722
|
+
risk_free_rate * k_disc * n_neg_d2 -
|
|
723
|
+
dividend_yield * s_disc * n_neg_d1
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
vega_unit = s_disc * pdf_d1 * sqrt_t
|
|
727
|
+
rho_unit = type == :call ? k_disc * time_to_expiry * nd2 : -k_disc * time_to_expiry * n_neg_d2
|
|
728
|
+
|
|
729
|
+
Greeks.new(
|
|
730
|
+
delta: delta,
|
|
731
|
+
gamma: gamma,
|
|
732
|
+
theta: theta_year / 365.0,
|
|
733
|
+
vega: vega_unit / 100.0,
|
|
734
|
+
rho: rho_unit / 100.0,
|
|
735
|
+
price: price,
|
|
736
|
+
model: :black_scholes_european
|
|
737
|
+
)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def price(**args)
|
|
741
|
+
calculate(**args).price
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def d1_d2(strike, spot, t, sigma, r, q)
|
|
745
|
+
sqrt_t = ::Math.sqrt(t)
|
|
746
|
+
d1 = (::Math.log(spot / strike) + (r - q + 0.5 * sigma**2) * t) / (sigma * sqrt_t)
|
|
747
|
+
d2 = d1 - sigma * sqrt_t
|
|
748
|
+
[d1, d2]
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
- [ ] **Step 4: Run, expect pass**
|
|
756
|
+
|
|
757
|
+
Run: `bundle exec rspec spec/engines/black_scholes_european_spec.rb`
|
|
758
|
+
Expected: 5 examples, 0 failures.
|
|
759
|
+
|
|
760
|
+
- [ ] **Step 5: Commit**
|
|
761
|
+
|
|
762
|
+
```bash
|
|
763
|
+
git add lib/pure_greeks/engines/black_scholes_european.rb spec/engines/black_scholes_european_spec.rb
|
|
764
|
+
git commit -m "feat: add Black-Scholes European Greeks (delta, gamma, theta, vega, rho)"
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
---
|
|
768
|
+
|
|
769
|
+
## Phase 3: CRR Binomial American Engine
|
|
770
|
+
|
|
771
|
+
### Task 10: CRR tree builder
|
|
772
|
+
|
|
773
|
+
**Files:**
|
|
774
|
+
- Create: `lib/pure_greeks/engines/crr_binomial_american.rb` (incremental — tree first)
|
|
775
|
+
- Test: `spec/engines/crr_binomial_american_spec.rb`
|
|
776
|
+
|
|
777
|
+
**CRR parameters** (Cox-Ross-Rubinstein):
|
|
778
|
+
```
|
|
779
|
+
dt = T / N
|
|
780
|
+
u = exp(σ·√dt)
|
|
781
|
+
d = 1/u
|
|
782
|
+
p = (exp((r-q)·dt) - d) / (u - d) # risk-neutral up-probability
|
|
783
|
+
disc = exp(-r·dt) # one-step discount
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
The tree has N+1 leaf nodes at time T, with spot at leaf `j` being `S · u^(N-j) · d^j` for `j ∈ [0, N]`.
|
|
787
|
+
|
|
788
|
+
- [ ] **Step 1: Write failing test for tree parameters**
|
|
789
|
+
|
|
790
|
+
Create `spec/engines/crr_binomial_american_spec.rb`:
|
|
791
|
+
|
|
792
|
+
```ruby
|
|
793
|
+
require "pure_greeks/engines/crr_binomial_american"
|
|
794
|
+
|
|
795
|
+
RSpec.describe PureGreeks::Engines::CrrBinomialAmerican do
|
|
796
|
+
describe ".tree_parameters" do
|
|
797
|
+
it "computes u, d, p, disc for given inputs" do
|
|
798
|
+
params = described_class.tree_parameters(
|
|
799
|
+
time_to_expiry: 1.0,
|
|
800
|
+
steps: 200,
|
|
801
|
+
implied_volatility: 0.20,
|
|
802
|
+
risk_free_rate: 0.05,
|
|
803
|
+
dividend_yield: 0.0
|
|
804
|
+
)
|
|
805
|
+
dt = 1.0 / 200.0
|
|
806
|
+
expect(params[:dt]).to be_within(1e-12).of(dt)
|
|
807
|
+
expect(params[:u]).to be_within(1e-10).of(::Math.exp(0.20 * ::Math.sqrt(dt)))
|
|
808
|
+
expect(params[:d]).to be_within(1e-10).of(1.0 / params[:u])
|
|
809
|
+
expect(params[:p]).to be > 0.0
|
|
810
|
+
expect(params[:p]).to be < 1.0
|
|
811
|
+
expect(params[:disc]).to be_within(1e-10).of(::Math.exp(-0.05 * dt))
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
- [ ] **Step 2: Run, expect failure**
|
|
818
|
+
|
|
819
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
820
|
+
Expected: LoadError.
|
|
821
|
+
|
|
822
|
+
- [ ] **Step 3: Implement**
|
|
823
|
+
|
|
824
|
+
Create `lib/pure_greeks/engines/crr_binomial_american.rb`:
|
|
825
|
+
|
|
826
|
+
```ruby
|
|
827
|
+
module PureGreeks
|
|
828
|
+
module Engines
|
|
829
|
+
module CrrBinomialAmerican
|
|
830
|
+
DEFAULT_STEPS = 200
|
|
831
|
+
|
|
832
|
+
module_function
|
|
833
|
+
|
|
834
|
+
def tree_parameters(time_to_expiry:, steps:, implied_volatility:, risk_free_rate:, dividend_yield:)
|
|
835
|
+
dt = time_to_expiry / steps.to_f
|
|
836
|
+
u = ::Math.exp(implied_volatility * ::Math.sqrt(dt))
|
|
837
|
+
d = 1.0 / u
|
|
838
|
+
p = (::Math.exp((risk_free_rate - dividend_yield) * dt) - d) / (u - d)
|
|
839
|
+
disc = ::Math.exp(-risk_free_rate * dt)
|
|
840
|
+
{ dt: dt, u: u, d: d, p: p, disc: disc }
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
end
|
|
844
|
+
end
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
- [ ] **Step 4: Run, expect pass**
|
|
848
|
+
|
|
849
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
850
|
+
Expected: 1 example, 0 failures.
|
|
851
|
+
|
|
852
|
+
- [ ] **Step 5: Commit**
|
|
853
|
+
|
|
854
|
+
```bash
|
|
855
|
+
git add lib/pure_greeks/engines/crr_binomial_american.rb spec/engines/crr_binomial_american_spec.rb
|
|
856
|
+
git commit -m "feat: add CRR tree parameter computation"
|
|
857
|
+
```
|
|
858
|
+
|
|
859
|
+
### Task 11: CRR backward induction — pricing only
|
|
860
|
+
|
|
861
|
+
**Files:**
|
|
862
|
+
- Modify: `lib/pure_greeks/engines/crr_binomial_american.rb`
|
|
863
|
+
- Modify: `spec/engines/crr_binomial_american_spec.rb`
|
|
864
|
+
|
|
865
|
+
**Algorithm:**
|
|
866
|
+
1. Build leaf payoff vector: for each leaf `j ∈ [0, N]`, `payoff[j] = max(0, ε·(S·u^(N-j)·d^j − K))` where `ε = +1` for call, `-1` for put.
|
|
867
|
+
2. Iterate backward: at step `i = N-1, …, 0`, for each node `j ∈ [0, i]`:
|
|
868
|
+
- `continuation = disc · (p · V[j] + (1-p) · V[j+1])`
|
|
869
|
+
- `spot_at_node = S · u^(i-j) · d^j`
|
|
870
|
+
- `intrinsic = max(0, ε·(spot_at_node − K))`
|
|
871
|
+
- `V[j] = max(continuation, intrinsic)` ← American early-exercise
|
|
872
|
+
3. Return `V[0]`.
|
|
873
|
+
|
|
874
|
+
**Reference value:** American put with no dividends and no early exercise should equal European put. Use Hull params (S=K=100, σ=20%, r=5%, q=0, T=1):
|
|
875
|
+
- European put = 5.5735
|
|
876
|
+
- American put = 6.0395 (early exercise has value)
|
|
877
|
+
- American call (no div) = European call = 10.4506
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 1: Write failing test**
|
|
880
|
+
|
|
881
|
+
Append to `spec/engines/crr_binomial_american_spec.rb`:
|
|
882
|
+
|
|
883
|
+
```ruby
|
|
884
|
+
let(:hull_inputs) do
|
|
885
|
+
{
|
|
886
|
+
type: :call,
|
|
887
|
+
strike: 100.0,
|
|
888
|
+
underlying_price: 100.0,
|
|
889
|
+
time_to_expiry: 1.0,
|
|
890
|
+
implied_volatility: 0.20,
|
|
891
|
+
risk_free_rate: 0.05,
|
|
892
|
+
dividend_yield: 0.0,
|
|
893
|
+
steps: 200
|
|
894
|
+
}
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
describe ".price" do
|
|
898
|
+
it "American call with no dividends matches European call (Hull ATM)" do
|
|
899
|
+
expect(described_class.price(**hull_inputs)).to be_within(0.02).of(10.4506)
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
it "American put with dividends > European put (early exercise has value)" do
|
|
903
|
+
am_put = described_class.price(**hull_inputs.merge(type: :put))
|
|
904
|
+
expect(am_put).to be_within(0.05).of(6.0395)
|
|
905
|
+
expect(am_put).to be > 5.5735
|
|
906
|
+
end
|
|
907
|
+
end
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
- [ ] **Step 2: Run, expect failure**
|
|
911
|
+
|
|
912
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
913
|
+
Expected: NoMethodError.
|
|
914
|
+
|
|
915
|
+
- [ ] **Step 3: Implement backward induction**
|
|
916
|
+
|
|
917
|
+
Append to `lib/pure_greeks/engines/crr_binomial_american.rb` (inside the module):
|
|
918
|
+
|
|
919
|
+
```ruby
|
|
920
|
+
def price(type:, strike:, underlying_price:, time_to_expiry:, implied_volatility:, risk_free_rate:, dividend_yield:, steps: DEFAULT_STEPS)
|
|
921
|
+
params = tree_parameters(
|
|
922
|
+
time_to_expiry: time_to_expiry,
|
|
923
|
+
steps: steps,
|
|
924
|
+
implied_volatility: implied_volatility,
|
|
925
|
+
risk_free_rate: risk_free_rate,
|
|
926
|
+
dividend_yield: dividend_yield
|
|
927
|
+
)
|
|
928
|
+
backward_induct(type, strike, underlying_price, steps, params)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def backward_induct(type, strike, spot, steps, params)
|
|
932
|
+
u = params[:u]
|
|
933
|
+
d = params[:d]
|
|
934
|
+
p = params[:p]
|
|
935
|
+
disc = params[:disc]
|
|
936
|
+
sign = type == :call ? 1.0 : -1.0
|
|
937
|
+
|
|
938
|
+
values = Array.new(steps + 1)
|
|
939
|
+
(0..steps).each do |j|
|
|
940
|
+
spot_at_leaf = spot * (u**(steps - j)) * (d**j)
|
|
941
|
+
values[j] = [0.0, sign * (spot_at_leaf - strike)].max
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
(steps - 1).downto(0) do |i|
|
|
945
|
+
(0..i).each do |j|
|
|
946
|
+
continuation = disc * (p * values[j] + (1 - p) * values[j + 1])
|
|
947
|
+
spot_at_node = spot * (u**(i - j)) * (d**j)
|
|
948
|
+
intrinsic = [0.0, sign * (spot_at_node - strike)].max
|
|
949
|
+
values[j] = [continuation, intrinsic].max
|
|
950
|
+
end
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
values[0]
|
|
954
|
+
end
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
- [ ] **Step 4: Run, expect pass**
|
|
958
|
+
|
|
959
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
960
|
+
Expected: 3 examples, 0 failures.
|
|
961
|
+
|
|
962
|
+
- [ ] **Step 5: Commit**
|
|
963
|
+
|
|
964
|
+
```bash
|
|
965
|
+
git add lib/pure_greeks/engines/crr_binomial_american.rb spec/engines/crr_binomial_american_spec.rb
|
|
966
|
+
git commit -m "feat: add CRR backward induction with early-exercise"
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
### Task 12: CRR delta + gamma extraction from tree
|
|
970
|
+
|
|
971
|
+
**Files:**
|
|
972
|
+
- Modify: `lib/pure_greeks/engines/crr_binomial_american.rb`
|
|
973
|
+
- Modify: `spec/engines/crr_binomial_american_spec.rb`
|
|
974
|
+
|
|
975
|
+
**Approach:** During backward induction, retain the values at step `i = 2` (three nodes). Then:
|
|
976
|
+
|
|
977
|
+
```
|
|
978
|
+
Delta ≈ (V[0]_step1 - V[1]_step1) / (S·u - S·d)
|
|
979
|
+
Gamma ≈ (Δ_upper - Δ_lower) / (0.5·(S·u² - S·d²))
|
|
980
|
+
where Δ_upper = (V[0]_step2 - V[1]_step2) / (S·u² - S·u·d)
|
|
981
|
+
Δ_lower = (V[1]_step2 - V[2]_step2) / (S·u·d - S·d²)
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
This is the standard "free" Delta/Gamma extraction technique used by QuantLib's `BinomialVanillaEngine`.
|
|
985
|
+
|
|
986
|
+
- [ ] **Step 1: Write failing test**
|
|
987
|
+
|
|
988
|
+
Append to `spec/engines/crr_binomial_american_spec.rb`:
|
|
989
|
+
|
|
990
|
+
```ruby
|
|
991
|
+
describe ".calculate" do
|
|
992
|
+
it "returns Greeks struct for ATM American call (matches BS within tree tolerance)" do
|
|
993
|
+
g = described_class.calculate(**hull_inputs)
|
|
994
|
+
expect(g.price).to be_within(0.02).of(10.4506)
|
|
995
|
+
expect(g.delta).to be_within(0.005).of(0.6368)
|
|
996
|
+
expect(g.gamma).to be_within(0.001).of(0.01876)
|
|
997
|
+
expect(g.model).to eq(:crr_binomial_american)
|
|
998
|
+
end
|
|
999
|
+
end
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1003
|
+
|
|
1004
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
1005
|
+
Expected: NoMethodError on `.calculate`.
|
|
1006
|
+
|
|
1007
|
+
- [ ] **Step 3: Refactor — capture step-1 and step-2 values**
|
|
1008
|
+
|
|
1009
|
+
Modify `backward_induct` to optionally return intermediate values, and add a `calculate` method. Replace the `def price` and `def backward_induct` block with:
|
|
1010
|
+
|
|
1011
|
+
```ruby
|
|
1012
|
+
def calculate(type:, strike:, underlying_price:, time_to_expiry:, implied_volatility:, risk_free_rate:, dividend_yield:, steps: DEFAULT_STEPS)
|
|
1013
|
+
params = tree_parameters(
|
|
1014
|
+
time_to_expiry: time_to_expiry,
|
|
1015
|
+
steps: steps,
|
|
1016
|
+
implied_volatility: implied_volatility,
|
|
1017
|
+
risk_free_rate: risk_free_rate,
|
|
1018
|
+
dividend_yield: dividend_yield
|
|
1019
|
+
)
|
|
1020
|
+
result = backward_induct_with_intermediates(type, strike, underlying_price, steps, params)
|
|
1021
|
+
price = result[:price]
|
|
1022
|
+
v_step1 = result[:step1]
|
|
1023
|
+
v_step2 = result[:step2]
|
|
1024
|
+
u = params[:u]
|
|
1025
|
+
d = params[:d]
|
|
1026
|
+
|
|
1027
|
+
# Delta from step 1
|
|
1028
|
+
delta = (v_step1[0] - v_step1[1]) / (underlying_price * u - underlying_price * d)
|
|
1029
|
+
|
|
1030
|
+
# Gamma from step 2
|
|
1031
|
+
s_uu = underlying_price * u * u
|
|
1032
|
+
s_ud = underlying_price * u * d
|
|
1033
|
+
s_dd = underlying_price * d * d
|
|
1034
|
+
delta_upper = (v_step2[0] - v_step2[1]) / (s_uu - s_ud)
|
|
1035
|
+
delta_lower = (v_step2[1] - v_step2[2]) / (s_ud - s_dd)
|
|
1036
|
+
gamma = (delta_upper - delta_lower) / (0.5 * (s_uu - s_dd))
|
|
1037
|
+
|
|
1038
|
+
# Theta and vega/rho deferred to next tasks
|
|
1039
|
+
PureGreeks::Greeks.new(
|
|
1040
|
+
delta: delta,
|
|
1041
|
+
gamma: gamma,
|
|
1042
|
+
theta: 0.0,
|
|
1043
|
+
vega: 0.0,
|
|
1044
|
+
rho: 0.0,
|
|
1045
|
+
price: price,
|
|
1046
|
+
model: :crr_binomial_american
|
|
1047
|
+
)
|
|
1048
|
+
end
|
|
1049
|
+
|
|
1050
|
+
def price(**args)
|
|
1051
|
+
calculate(**args).price
|
|
1052
|
+
end
|
|
1053
|
+
|
|
1054
|
+
def backward_induct_with_intermediates(type, strike, spot, steps, params)
|
|
1055
|
+
u = params[:u]
|
|
1056
|
+
d = params[:d]
|
|
1057
|
+
p = params[:p]
|
|
1058
|
+
disc = params[:disc]
|
|
1059
|
+
sign = type == :call ? 1.0 : -1.0
|
|
1060
|
+
|
|
1061
|
+
values = Array.new(steps + 1)
|
|
1062
|
+
(0..steps).each do |j|
|
|
1063
|
+
spot_at_leaf = spot * (u**(steps - j)) * (d**j)
|
|
1064
|
+
values[j] = [0.0, sign * (spot_at_leaf - strike)].max
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
step2 = nil
|
|
1068
|
+
step1 = nil
|
|
1069
|
+
|
|
1070
|
+
(steps - 1).downto(0) do |i|
|
|
1071
|
+
(0..i).each do |j|
|
|
1072
|
+
continuation = disc * (p * values[j] + (1 - p) * values[j + 1])
|
|
1073
|
+
spot_at_node = spot * (u**(i - j)) * (d**j)
|
|
1074
|
+
intrinsic = [0.0, sign * (spot_at_node - strike)].max
|
|
1075
|
+
values[j] = [continuation, intrinsic].max
|
|
1076
|
+
end
|
|
1077
|
+
step2 = values[0..2].dup if i == 2
|
|
1078
|
+
step1 = values[0..1].dup if i == 1
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
{ price: values[0], step1: step1, step2: step2 }
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
require "pure_greeks/greeks"
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
Move the `require "pure_greeks/greeks"` to the top of the file (above the module definition).
|
|
1088
|
+
|
|
1089
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1090
|
+
|
|
1091
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
1092
|
+
Expected: 4 examples, 0 failures.
|
|
1093
|
+
|
|
1094
|
+
- [ ] **Step 5: Commit**
|
|
1095
|
+
|
|
1096
|
+
```bash
|
|
1097
|
+
git add lib/pure_greeks/engines/crr_binomial_american.rb spec/engines/crr_binomial_american_spec.rb
|
|
1098
|
+
git commit -m "feat: extract delta and gamma from CRR tree"
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
### Task 13: CRR theta extraction
|
|
1102
|
+
|
|
1103
|
+
**Files:**
|
|
1104
|
+
- Modify: `lib/pure_greeks/engines/crr_binomial_american.rb`
|
|
1105
|
+
- Modify: `spec/engines/crr_binomial_american_spec.rb`
|
|
1106
|
+
|
|
1107
|
+
**Approach:** Theta is approximated by `(V_step2[1] - V_step0) / (2·dt)` then divided by 365 to convert to per-day. The middle-node value at step 2 corresponds to spot ≈ S, two time steps earlier. This is the standard QuantLib `BinomialVanillaEngine` theta technique.
|
|
1108
|
+
|
|
1109
|
+
- [ ] **Step 1: Write failing test**
|
|
1110
|
+
|
|
1111
|
+
Append to `describe ".calculate"`:
|
|
1112
|
+
|
|
1113
|
+
```ruby
|
|
1114
|
+
it "computes theta close to Black-Scholes equivalent" do
|
|
1115
|
+
g = described_class.calculate(**hull_inputs)
|
|
1116
|
+
# BS theta for ATM call ≈ -0.01757 per day
|
|
1117
|
+
expect(g.theta).to be_within(0.002).of(-0.01757)
|
|
1118
|
+
end
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1122
|
+
|
|
1123
|
+
Expected: theta is 0.0, test fails.
|
|
1124
|
+
|
|
1125
|
+
- [ ] **Step 3: Implement theta**
|
|
1126
|
+
|
|
1127
|
+
In `calculate`, replace `theta: 0.0,` with:
|
|
1128
|
+
|
|
1129
|
+
```ruby
|
|
1130
|
+
theta: (v_step2[1] - price) / (2.0 * params[:dt]) / 365.0,
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1134
|
+
|
|
1135
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
1136
|
+
Expected: 5 examples, 0 failures.
|
|
1137
|
+
|
|
1138
|
+
- [ ] **Step 5: Commit**
|
|
1139
|
+
|
|
1140
|
+
```bash
|
|
1141
|
+
git add lib/pure_greeks/engines/crr_binomial_american.rb spec/engines/crr_binomial_american_spec.rb
|
|
1142
|
+
git commit -m "feat: extract theta from CRR tree"
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
### Task 14: CRR vega + rho via finite difference
|
|
1146
|
+
|
|
1147
|
+
**Files:**
|
|
1148
|
+
- Modify: `lib/pure_greeks/engines/crr_binomial_american.rb`
|
|
1149
|
+
- Modify: `spec/engines/crr_binomial_american_spec.rb`
|
|
1150
|
+
|
|
1151
|
+
**Approach** (matches Tenor reference):
|
|
1152
|
+
|
|
1153
|
+
```
|
|
1154
|
+
vega = (price(σ + 0.01) - price(σ)) / (0.01 * 100) # per 1% vol move
|
|
1155
|
+
rho = (price(r + 0.01) - price(r)) / (0.01 * 100) # per 1% rate move
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
We rebuild the tree with bumped parameters. This is 2 extra full tree solves per option — slow but correct. Performance optimization (reusing a baseline tree) is deferred to Phase 8.
|
|
1159
|
+
|
|
1160
|
+
- [ ] **Step 1: Write failing test**
|
|
1161
|
+
|
|
1162
|
+
Append to `describe ".calculate"`:
|
|
1163
|
+
|
|
1164
|
+
```ruby
|
|
1165
|
+
it "computes vega close to Black-Scholes equivalent" do
|
|
1166
|
+
g = described_class.calculate(**hull_inputs)
|
|
1167
|
+
expect(g.vega).to be_within(0.005).of(0.37524)
|
|
1168
|
+
end
|
|
1169
|
+
|
|
1170
|
+
it "computes rho close to Black-Scholes equivalent" do
|
|
1171
|
+
g = described_class.calculate(**hull_inputs)
|
|
1172
|
+
expect(g.rho).to be_within(0.01).of(0.53232)
|
|
1173
|
+
end
|
|
1174
|
+
```
|
|
1175
|
+
|
|
1176
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1177
|
+
|
|
1178
|
+
- [ ] **Step 3: Implement bumped pricing**
|
|
1179
|
+
|
|
1180
|
+
Replace `vega: 0.0,` and `rho: 0.0,` lines in `calculate` with full implementations. Replace the entire `calculate` method body, after the gamma computation, with:
|
|
1181
|
+
|
|
1182
|
+
```ruby
|
|
1183
|
+
# Bump vol for vega (per 1% move)
|
|
1184
|
+
bumped_vol_params = tree_parameters(
|
|
1185
|
+
time_to_expiry: time_to_expiry,
|
|
1186
|
+
steps: steps,
|
|
1187
|
+
implied_volatility: implied_volatility + 0.01,
|
|
1188
|
+
risk_free_rate: risk_free_rate,
|
|
1189
|
+
dividend_yield: dividend_yield
|
|
1190
|
+
)
|
|
1191
|
+
price_vol_up = backward_induct_with_intermediates(type, strike, underlying_price, steps, bumped_vol_params)[:price]
|
|
1192
|
+
vega = (price_vol_up - price) / (0.01 * 100.0)
|
|
1193
|
+
|
|
1194
|
+
# Bump rate for rho (per 1% move)
|
|
1195
|
+
bumped_rate_params = tree_parameters(
|
|
1196
|
+
time_to_expiry: time_to_expiry,
|
|
1197
|
+
steps: steps,
|
|
1198
|
+
implied_volatility: implied_volatility,
|
|
1199
|
+
risk_free_rate: risk_free_rate + 0.01,
|
|
1200
|
+
dividend_yield: dividend_yield
|
|
1201
|
+
)
|
|
1202
|
+
price_rate_up = backward_induct_with_intermediates(type, strike, underlying_price, steps, bumped_rate_params)[:price]
|
|
1203
|
+
rho = (price_rate_up - price) / (0.01 * 100.0)
|
|
1204
|
+
|
|
1205
|
+
PureGreeks::Greeks.new(
|
|
1206
|
+
delta: delta,
|
|
1207
|
+
gamma: gamma,
|
|
1208
|
+
theta: (v_step2[1] - price) / (2.0 * params[:dt]) / 365.0,
|
|
1209
|
+
vega: vega,
|
|
1210
|
+
rho: rho,
|
|
1211
|
+
price: price,
|
|
1212
|
+
model: :crr_binomial_american
|
|
1213
|
+
)
|
|
1214
|
+
end
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
(Replace the existing `PureGreeks::Greeks.new(...)` block at the end of `calculate`.)
|
|
1218
|
+
|
|
1219
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1220
|
+
|
|
1221
|
+
Run: `bundle exec rspec spec/engines/crr_binomial_american_spec.rb`
|
|
1222
|
+
Expected: 7 examples, 0 failures.
|
|
1223
|
+
|
|
1224
|
+
- [ ] **Step 5: Commit**
|
|
1225
|
+
|
|
1226
|
+
```bash
|
|
1227
|
+
git add lib/pure_greeks/engines/crr_binomial_american.rb spec/engines/crr_binomial_american_spec.rb
|
|
1228
|
+
git commit -m "feat: add finite-difference vega and rho to CRR engine"
|
|
1229
|
+
```
|
|
1230
|
+
|
|
1231
|
+
---
|
|
1232
|
+
|
|
1233
|
+
## Phase 4: Intrinsic Engine + Fallback Chain
|
|
1234
|
+
|
|
1235
|
+
### Task 15: Intrinsic engine
|
|
1236
|
+
|
|
1237
|
+
**Files:**
|
|
1238
|
+
- Create: `lib/pure_greeks/engines/intrinsic.rb`
|
|
1239
|
+
- Test: `spec/engines/intrinsic_spec.rb`
|
|
1240
|
+
|
|
1241
|
+
For zero/negative IV. Returns intrinsic value, binary delta, zeros elsewhere.
|
|
1242
|
+
|
|
1243
|
+
- [ ] **Step 1: Write failing tests**
|
|
1244
|
+
|
|
1245
|
+
Create `spec/engines/intrinsic_spec.rb`:
|
|
1246
|
+
|
|
1247
|
+
```ruby
|
|
1248
|
+
require "pure_greeks/engines/intrinsic"
|
|
1249
|
+
|
|
1250
|
+
RSpec.describe PureGreeks::Engines::Intrinsic do
|
|
1251
|
+
describe ".calculate" do
|
|
1252
|
+
it "in-the-money call: intrinsic = spot - strike, delta = 1" do
|
|
1253
|
+
g = described_class.calculate(type: :call, strike: 100.0, underlying_price: 110.0)
|
|
1254
|
+
expect(g.price).to eq(10.0)
|
|
1255
|
+
expect(g.delta).to eq(1.0)
|
|
1256
|
+
expect(g.gamma).to eq(0.0)
|
|
1257
|
+
expect(g.model).to eq(:intrinsic)
|
|
1258
|
+
end
|
|
1259
|
+
|
|
1260
|
+
it "out-of-the-money call: intrinsic = 0, delta = 0" do
|
|
1261
|
+
g = described_class.calculate(type: :call, strike: 100.0, underlying_price: 90.0)
|
|
1262
|
+
expect(g.price).to eq(0.0)
|
|
1263
|
+
expect(g.delta).to eq(0.0)
|
|
1264
|
+
end
|
|
1265
|
+
|
|
1266
|
+
it "in-the-money put: intrinsic = strike - spot, delta = -1" do
|
|
1267
|
+
g = described_class.calculate(type: :put, strike: 100.0, underlying_price: 90.0)
|
|
1268
|
+
expect(g.price).to eq(10.0)
|
|
1269
|
+
expect(g.delta).to eq(-1.0)
|
|
1270
|
+
end
|
|
1271
|
+
|
|
1272
|
+
it "out-of-the-money put: intrinsic = 0, delta = 0" do
|
|
1273
|
+
g = described_class.calculate(type: :put, strike: 100.0, underlying_price: 110.0)
|
|
1274
|
+
expect(g.price).to eq(0.0)
|
|
1275
|
+
expect(g.delta).to eq(0.0)
|
|
1276
|
+
end
|
|
1277
|
+
end
|
|
1278
|
+
end
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1282
|
+
|
|
1283
|
+
Run: `bundle exec rspec spec/engines/intrinsic_spec.rb`
|
|
1284
|
+
Expected: LoadError.
|
|
1285
|
+
|
|
1286
|
+
- [ ] **Step 3: Implement**
|
|
1287
|
+
|
|
1288
|
+
Create `lib/pure_greeks/engines/intrinsic.rb`:
|
|
1289
|
+
|
|
1290
|
+
```ruby
|
|
1291
|
+
require "pure_greeks/greeks"
|
|
1292
|
+
|
|
1293
|
+
module PureGreeks
|
|
1294
|
+
module Engines
|
|
1295
|
+
module Intrinsic
|
|
1296
|
+
module_function
|
|
1297
|
+
|
|
1298
|
+
def calculate(type:, strike:, underlying_price:)
|
|
1299
|
+
if type == :call
|
|
1300
|
+
price = [0.0, underlying_price - strike].max
|
|
1301
|
+
delta = underlying_price > strike ? 1.0 : 0.0
|
|
1302
|
+
else
|
|
1303
|
+
price = [0.0, strike - underlying_price].max
|
|
1304
|
+
delta = underlying_price < strike ? -1.0 : 0.0
|
|
1305
|
+
end
|
|
1306
|
+
|
|
1307
|
+
Greeks.new(
|
|
1308
|
+
delta: delta,
|
|
1309
|
+
gamma: 0.0,
|
|
1310
|
+
theta: 0.0,
|
|
1311
|
+
vega: 0.0,
|
|
1312
|
+
rho: 0.0,
|
|
1313
|
+
price: price,
|
|
1314
|
+
model: :intrinsic
|
|
1315
|
+
)
|
|
1316
|
+
end
|
|
1317
|
+
end
|
|
1318
|
+
end
|
|
1319
|
+
end
|
|
1320
|
+
```
|
|
1321
|
+
|
|
1322
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1323
|
+
|
|
1324
|
+
Run: `bundle exec rspec spec/engines/intrinsic_spec.rb`
|
|
1325
|
+
Expected: 4 examples, 0 failures.
|
|
1326
|
+
|
|
1327
|
+
- [ ] **Step 5: Commit**
|
|
1328
|
+
|
|
1329
|
+
```bash
|
|
1330
|
+
git add lib/pure_greeks/engines/intrinsic.rb spec/engines/intrinsic_spec.rb
|
|
1331
|
+
git commit -m "feat: add intrinsic value engine for zero/negative IV"
|
|
1332
|
+
```
|
|
1333
|
+
|
|
1334
|
+
### Task 16: Fallback chain orchestrator
|
|
1335
|
+
|
|
1336
|
+
**Files:**
|
|
1337
|
+
- Create: `lib/pure_greeks/engines/fallback_chain.rb`
|
|
1338
|
+
- Test: `spec/engines/fallback_chain_spec.rb`
|
|
1339
|
+
|
|
1340
|
+
Replicates the Tenor 3-tier order. American-exercise options try CRR Binomial first; European options skip directly to Black-Scholes. Both fall back to BS European, then Intrinsic.
|
|
1341
|
+
|
|
1342
|
+
- [ ] **Step 1: Write failing tests**
|
|
1343
|
+
|
|
1344
|
+
Create `spec/engines/fallback_chain_spec.rb`:
|
|
1345
|
+
|
|
1346
|
+
```ruby
|
|
1347
|
+
require "pure_greeks/engines/fallback_chain"
|
|
1348
|
+
|
|
1349
|
+
RSpec.describe PureGreeks::Engines::FallbackChain do
|
|
1350
|
+
let(:base_inputs) do
|
|
1351
|
+
{
|
|
1352
|
+
type: :call,
|
|
1353
|
+
strike: 100.0,
|
|
1354
|
+
underlying_price: 100.0,
|
|
1355
|
+
time_to_expiry: 1.0,
|
|
1356
|
+
implied_volatility: 0.20,
|
|
1357
|
+
risk_free_rate: 0.05,
|
|
1358
|
+
dividend_yield: 0.0
|
|
1359
|
+
}
|
|
1360
|
+
end
|
|
1361
|
+
|
|
1362
|
+
describe ".calculate" do
|
|
1363
|
+
it "uses CRR for American exercise style" do
|
|
1364
|
+
g = described_class.calculate(exercise_style: :american, **base_inputs)
|
|
1365
|
+
expect(g.model).to eq(:crr_binomial_american)
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
it "uses BS European for European exercise style" do
|
|
1369
|
+
g = described_class.calculate(exercise_style: :european, **base_inputs)
|
|
1370
|
+
expect(g.model).to eq(:black_scholes_european)
|
|
1371
|
+
end
|
|
1372
|
+
|
|
1373
|
+
it "falls back to intrinsic when IV <= 0" do
|
|
1374
|
+
g = described_class.calculate(exercise_style: :american, **base_inputs.merge(implied_volatility: 0.0))
|
|
1375
|
+
expect(g.model).to eq(:intrinsic)
|
|
1376
|
+
end
|
|
1377
|
+
|
|
1378
|
+
it "falls back to BS European when CRR raises" do
|
|
1379
|
+
allow(PureGreeks::Engines::CrrBinomialAmerican).to receive(:calculate).and_raise("simulated CRR failure")
|
|
1380
|
+
g = described_class.calculate(exercise_style: :american, **base_inputs)
|
|
1381
|
+
expect(g.model).to eq(:black_scholes_european)
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
it "falls back to intrinsic when both CRR and BS raise" do
|
|
1385
|
+
allow(PureGreeks::Engines::CrrBinomialAmerican).to receive(:calculate).and_raise("simulated CRR failure")
|
|
1386
|
+
allow(PureGreeks::Engines::BlackScholesEuropean).to receive(:calculate).and_raise("simulated BS failure")
|
|
1387
|
+
g = described_class.calculate(exercise_style: :american, **base_inputs)
|
|
1388
|
+
expect(g.model).to eq(:intrinsic)
|
|
1389
|
+
end
|
|
1390
|
+
end
|
|
1391
|
+
end
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1395
|
+
|
|
1396
|
+
Run: `bundle exec rspec spec/engines/fallback_chain_spec.rb`
|
|
1397
|
+
Expected: LoadError.
|
|
1398
|
+
|
|
1399
|
+
- [ ] **Step 3: Implement**
|
|
1400
|
+
|
|
1401
|
+
Create `lib/pure_greeks/engines/fallback_chain.rb`:
|
|
1402
|
+
|
|
1403
|
+
```ruby
|
|
1404
|
+
require "pure_greeks/engines/black_scholes_european"
|
|
1405
|
+
require "pure_greeks/engines/crr_binomial_american"
|
|
1406
|
+
require "pure_greeks/engines/intrinsic"
|
|
1407
|
+
|
|
1408
|
+
module PureGreeks
|
|
1409
|
+
module Engines
|
|
1410
|
+
module FallbackChain
|
|
1411
|
+
module_function
|
|
1412
|
+
|
|
1413
|
+
def calculate(exercise_style:, type:, strike:, underlying_price:, time_to_expiry:, implied_volatility:, risk_free_rate:, dividend_yield:)
|
|
1414
|
+
if implied_volatility <= 0.0
|
|
1415
|
+
return Intrinsic.calculate(type: type, strike: strike, underlying_price: underlying_price)
|
|
1416
|
+
end
|
|
1417
|
+
|
|
1418
|
+
engine_args = {
|
|
1419
|
+
type: type,
|
|
1420
|
+
strike: strike,
|
|
1421
|
+
underlying_price: underlying_price,
|
|
1422
|
+
time_to_expiry: time_to_expiry,
|
|
1423
|
+
implied_volatility: implied_volatility,
|
|
1424
|
+
risk_free_rate: risk_free_rate,
|
|
1425
|
+
dividend_yield: dividend_yield
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if exercise_style == :american
|
|
1429
|
+
begin
|
|
1430
|
+
return CrrBinomialAmerican.calculate(**engine_args)
|
|
1431
|
+
rescue StandardError
|
|
1432
|
+
# fall through to BS
|
|
1433
|
+
end
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1436
|
+
begin
|
|
1437
|
+
return BlackScholesEuropean.calculate(**engine_args)
|
|
1438
|
+
rescue StandardError
|
|
1439
|
+
# fall through to intrinsic
|
|
1440
|
+
end
|
|
1441
|
+
|
|
1442
|
+
Intrinsic.calculate(type: type, strike: strike, underlying_price: underlying_price)
|
|
1443
|
+
end
|
|
1444
|
+
end
|
|
1445
|
+
end
|
|
1446
|
+
end
|
|
1447
|
+
```
|
|
1448
|
+
|
|
1449
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1450
|
+
|
|
1451
|
+
Run: `bundle exec rspec spec/engines/fallback_chain_spec.rb`
|
|
1452
|
+
Expected: 5 examples, 0 failures.
|
|
1453
|
+
|
|
1454
|
+
- [ ] **Step 5: Commit**
|
|
1455
|
+
|
|
1456
|
+
```bash
|
|
1457
|
+
git add lib/pure_greeks/engines/fallback_chain.rb spec/engines/fallback_chain_spec.rb
|
|
1458
|
+
git commit -m "feat: add fallback chain orchestrator (American → European → Intrinsic)"
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
---
|
|
1462
|
+
|
|
1463
|
+
## Phase 5: Public OO API
|
|
1464
|
+
|
|
1465
|
+
### Task 17: Option class — input validation + lazy Greeks
|
|
1466
|
+
|
|
1467
|
+
**Files:**
|
|
1468
|
+
- Create: `lib/pure_greeks/option.rb`
|
|
1469
|
+
- Modify: `lib/pure_greeks.rb`
|
|
1470
|
+
- Test: `spec/option_spec.rb`
|
|
1471
|
+
|
|
1472
|
+
The public OO entry. Validates inputs, computes time-to-expiry from dates, lazily delegates to `FallbackChain`, caches the Greeks struct.
|
|
1473
|
+
|
|
1474
|
+
- [ ] **Step 1: Write failing tests**
|
|
1475
|
+
|
|
1476
|
+
Create `spec/option_spec.rb`:
|
|
1477
|
+
|
|
1478
|
+
```ruby
|
|
1479
|
+
require "pure_greeks"
|
|
1480
|
+
|
|
1481
|
+
RSpec.describe PureGreeks::Option do
|
|
1482
|
+
let(:valuation_date) { Date.new(2026, 4, 26) }
|
|
1483
|
+
let(:expiration) { Date.new(2027, 4, 26) }
|
|
1484
|
+
let(:base_args) do
|
|
1485
|
+
{
|
|
1486
|
+
exercise_style: :american,
|
|
1487
|
+
type: :call,
|
|
1488
|
+
strike: 100.0,
|
|
1489
|
+
expiration: expiration,
|
|
1490
|
+
underlying_price: 100.0,
|
|
1491
|
+
implied_volatility: 0.20,
|
|
1492
|
+
risk_free_rate: 0.05,
|
|
1493
|
+
dividend_yield: 0.0,
|
|
1494
|
+
valuation_date: valuation_date
|
|
1495
|
+
}
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
describe "#initialize" do
|
|
1499
|
+
it "accepts valid inputs" do
|
|
1500
|
+
expect { described_class.new(**base_args) }.not_to raise_error
|
|
1501
|
+
end
|
|
1502
|
+
|
|
1503
|
+
it "rejects invalid exercise_style" do
|
|
1504
|
+
expect { described_class.new(**base_args.merge(exercise_style: :bermudan)) }
|
|
1505
|
+
.to raise_error(PureGreeks::InvalidInputError, /exercise_style/)
|
|
1506
|
+
end
|
|
1507
|
+
|
|
1508
|
+
it "rejects invalid type" do
|
|
1509
|
+
expect { described_class.new(**base_args.merge(type: :spread)) }
|
|
1510
|
+
.to raise_error(PureGreeks::InvalidInputError, /type/)
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1513
|
+
it "rejects negative strike" do
|
|
1514
|
+
expect { described_class.new(**base_args.merge(strike: -1.0)) }
|
|
1515
|
+
.to raise_error(PureGreeks::InvalidInputError)
|
|
1516
|
+
end
|
|
1517
|
+
|
|
1518
|
+
it "rejects negative spot" do
|
|
1519
|
+
expect { described_class.new(**base_args.merge(underlying_price: 0)) }
|
|
1520
|
+
.to raise_error(PureGreeks::InvalidInputError)
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
it "rejects expired contract" do
|
|
1524
|
+
expect { described_class.new(**base_args.merge(expiration: valuation_date - 1)) }
|
|
1525
|
+
.to raise_error(PureGreeks::ExpiredContractError)
|
|
1526
|
+
end
|
|
1527
|
+
end
|
|
1528
|
+
|
|
1529
|
+
describe "Greeks accessors" do
|
|
1530
|
+
subject(:option) { described_class.new(**base_args) }
|
|
1531
|
+
|
|
1532
|
+
it "exposes price, delta, gamma, theta, vega, rho" do
|
|
1533
|
+
expect(option.price).to be > 0
|
|
1534
|
+
expect(option.delta).to be_within(0.005).of(0.6368)
|
|
1535
|
+
expect(option.gamma).to be_within(0.001).of(0.01876)
|
|
1536
|
+
expect(option.theta).to be < 0
|
|
1537
|
+
expect(option.vega).to be > 0
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
it "exposes greeks struct" do
|
|
1541
|
+
expect(option.greeks).to be_a(PureGreeks::Greeks)
|
|
1542
|
+
end
|
|
1543
|
+
|
|
1544
|
+
it "caches the greeks computation" do
|
|
1545
|
+
expect(PureGreeks::Engines::FallbackChain).to receive(:calculate).once.and_call_original
|
|
1546
|
+
option.delta
|
|
1547
|
+
option.gamma
|
|
1548
|
+
option.greeks
|
|
1549
|
+
end
|
|
1550
|
+
|
|
1551
|
+
it "exposes calculation_model" do
|
|
1552
|
+
expect(option.calculation_model).to eq(:crr_binomial_american)
|
|
1553
|
+
end
|
|
1554
|
+
end
|
|
1555
|
+
end
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1559
|
+
|
|
1560
|
+
Run: `bundle exec rspec spec/option_spec.rb`
|
|
1561
|
+
Expected: LoadError or NameError on `PureGreeks::Option`.
|
|
1562
|
+
|
|
1563
|
+
- [ ] **Step 3: Implement Option class**
|
|
1564
|
+
|
|
1565
|
+
Create `lib/pure_greeks/option.rb`:
|
|
1566
|
+
|
|
1567
|
+
```ruby
|
|
1568
|
+
require "date"
|
|
1569
|
+
require "pure_greeks/errors"
|
|
1570
|
+
require "pure_greeks/engines/fallback_chain"
|
|
1571
|
+
|
|
1572
|
+
module PureGreeks
|
|
1573
|
+
class Option
|
|
1574
|
+
VALID_EXERCISE_STYLES = %i[american european].freeze
|
|
1575
|
+
VALID_TYPES = %i[call put].freeze
|
|
1576
|
+
DAYS_PER_YEAR = 365.0
|
|
1577
|
+
|
|
1578
|
+
attr_reader :exercise_style, :type, :strike, :expiration, :underlying_price,
|
|
1579
|
+
:implied_volatility, :risk_free_rate, :dividend_yield, :valuation_date
|
|
1580
|
+
|
|
1581
|
+
def initialize(exercise_style:, type:, strike:, expiration:, underlying_price:, risk_free_rate:, dividend_yield:, valuation_date:, implied_volatility: nil, market_price: nil)
|
|
1582
|
+
validate!(exercise_style, type, strike, underlying_price, expiration, valuation_date)
|
|
1583
|
+
|
|
1584
|
+
@exercise_style = exercise_style
|
|
1585
|
+
@type = type
|
|
1586
|
+
@strike = strike.to_f
|
|
1587
|
+
@expiration = expiration
|
|
1588
|
+
@underlying_price = underlying_price.to_f
|
|
1589
|
+
@implied_volatility = implied_volatility&.to_f
|
|
1590
|
+
@market_price = market_price&.to_f
|
|
1591
|
+
@risk_free_rate = risk_free_rate.to_f
|
|
1592
|
+
@dividend_yield = dividend_yield.to_f
|
|
1593
|
+
@valuation_date = valuation_date
|
|
1594
|
+
end
|
|
1595
|
+
|
|
1596
|
+
def time_to_expiry
|
|
1597
|
+
(@expiration - @valuation_date).to_f / DAYS_PER_YEAR
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1600
|
+
def greeks
|
|
1601
|
+
@greeks ||= compute_greeks
|
|
1602
|
+
end
|
|
1603
|
+
|
|
1604
|
+
def price
|
|
1605
|
+
greeks.price
|
|
1606
|
+
end
|
|
1607
|
+
|
|
1608
|
+
def delta
|
|
1609
|
+
greeks.delta
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
def gamma
|
|
1613
|
+
greeks.gamma
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
def theta
|
|
1617
|
+
greeks.theta
|
|
1618
|
+
end
|
|
1619
|
+
|
|
1620
|
+
def vega
|
|
1621
|
+
greeks.vega
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
def rho
|
|
1625
|
+
greeks.rho
|
|
1626
|
+
end
|
|
1627
|
+
|
|
1628
|
+
def calculation_model
|
|
1629
|
+
greeks.model
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
private
|
|
1633
|
+
|
|
1634
|
+
def compute_greeks
|
|
1635
|
+
Engines::FallbackChain.calculate(
|
|
1636
|
+
exercise_style: @exercise_style,
|
|
1637
|
+
type: @type,
|
|
1638
|
+
strike: @strike,
|
|
1639
|
+
underlying_price: @underlying_price,
|
|
1640
|
+
time_to_expiry: time_to_expiry,
|
|
1641
|
+
implied_volatility: @implied_volatility,
|
|
1642
|
+
risk_free_rate: @risk_free_rate,
|
|
1643
|
+
dividend_yield: @dividend_yield
|
|
1644
|
+
)
|
|
1645
|
+
end
|
|
1646
|
+
|
|
1647
|
+
def validate!(exercise_style, type, strike, spot, expiration, valuation_date)
|
|
1648
|
+
raise InvalidInputError, "exercise_style must be one of #{VALID_EXERCISE_STYLES}" unless VALID_EXERCISE_STYLES.include?(exercise_style)
|
|
1649
|
+
raise InvalidInputError, "type must be one of #{VALID_TYPES}" unless VALID_TYPES.include?(type)
|
|
1650
|
+
raise InvalidInputError, "strike must be positive" unless strike.is_a?(Numeric) && strike > 0
|
|
1651
|
+
raise InvalidInputError, "underlying_price must be positive" unless spot.is_a?(Numeric) && spot > 0
|
|
1652
|
+
raise ExpiredContractError, "contract expired on #{expiration}" if expiration <= valuation_date
|
|
1653
|
+
end
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
- [ ] **Step 4: Update top-level require**
|
|
1659
|
+
|
|
1660
|
+
Modify `lib/pure_greeks.rb` to require the public surface:
|
|
1661
|
+
|
|
1662
|
+
```ruby
|
|
1663
|
+
require "pure_greeks/version"
|
|
1664
|
+
require "pure_greeks/errors"
|
|
1665
|
+
require "pure_greeks/greeks"
|
|
1666
|
+
require "pure_greeks/option"
|
|
1667
|
+
|
|
1668
|
+
module PureGreeks
|
|
1669
|
+
end
|
|
1670
|
+
```
|
|
1671
|
+
|
|
1672
|
+
- [ ] **Step 5: Run, expect pass**
|
|
1673
|
+
|
|
1674
|
+
Run: `bundle exec rspec spec/option_spec.rb`
|
|
1675
|
+
Expected: 11 examples, 0 failures.
|
|
1676
|
+
|
|
1677
|
+
- [ ] **Step 6: Commit**
|
|
1678
|
+
|
|
1679
|
+
```bash
|
|
1680
|
+
git add lib/pure_greeks/option.rb lib/pure_greeks.rb spec/option_spec.rb
|
|
1681
|
+
git commit -m "feat: add Option public API with input validation and caching"
|
|
1682
|
+
```
|
|
1683
|
+
|
|
1684
|
+
---
|
|
1685
|
+
|
|
1686
|
+
## Phase 6: Implied Volatility Solver
|
|
1687
|
+
|
|
1688
|
+
### Task 18: Brent's method root finder
|
|
1689
|
+
|
|
1690
|
+
**Files:**
|
|
1691
|
+
- Create: `lib/pure_greeks/implied_volatility/brent_solver.rb`
|
|
1692
|
+
- Test: `spec/implied_volatility/brent_solver_spec.rb`
|
|
1693
|
+
|
|
1694
|
+
Brent's method is robust (combines bisection guarantee with secant/inverse-quadratic speed). Reference: Numerical Recipes ch. 9.3, or Wikipedia's pseudocode. We implement a generic 1D root finder, then layer the price-inversion logic on top in Task 19.
|
|
1695
|
+
|
|
1696
|
+
- [ ] **Step 1: Write failing tests**
|
|
1697
|
+
|
|
1698
|
+
Create `spec/implied_volatility/brent_solver_spec.rb`:
|
|
1699
|
+
|
|
1700
|
+
```ruby
|
|
1701
|
+
require "pure_greeks/implied_volatility/brent_solver"
|
|
1702
|
+
|
|
1703
|
+
RSpec.describe PureGreeks::ImpliedVolatility::BrentSolver do
|
|
1704
|
+
describe ".find_root" do
|
|
1705
|
+
it "finds root of x^2 - 4 in [1, 3] (= 2.0)" do
|
|
1706
|
+
root = described_class.find_root(lower: 1.0, upper: 3.0, tolerance: 1e-9) { |x| x**2 - 4.0 }
|
|
1707
|
+
expect(root).to be_within(1e-9).of(2.0)
|
|
1708
|
+
end
|
|
1709
|
+
|
|
1710
|
+
it "finds root of cos(x) - x near 0.7390851" do
|
|
1711
|
+
root = described_class.find_root(lower: 0.0, upper: 1.0, tolerance: 1e-9) { |x| ::Math.cos(x) - x }
|
|
1712
|
+
expect(root).to be_within(1e-9).of(0.7390851332151607)
|
|
1713
|
+
end
|
|
1714
|
+
|
|
1715
|
+
it "raises if root is not bracketed" do
|
|
1716
|
+
expect {
|
|
1717
|
+
described_class.find_root(lower: 5.0, upper: 10.0, tolerance: 1e-6) { |x| x**2 - 4.0 }
|
|
1718
|
+
}.to raise_error(PureGreeks::IVConvergenceError, /not bracketed/)
|
|
1719
|
+
end
|
|
1720
|
+
end
|
|
1721
|
+
end
|
|
1722
|
+
```
|
|
1723
|
+
|
|
1724
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1725
|
+
|
|
1726
|
+
Run: `bundle exec rspec spec/implied_volatility/brent_solver_spec.rb`
|
|
1727
|
+
Expected: LoadError.
|
|
1728
|
+
|
|
1729
|
+
- [ ] **Step 3: Implement Brent's method**
|
|
1730
|
+
|
|
1731
|
+
Create `lib/pure_greeks/implied_volatility/brent_solver.rb`:
|
|
1732
|
+
|
|
1733
|
+
```ruby
|
|
1734
|
+
require "pure_greeks/errors"
|
|
1735
|
+
|
|
1736
|
+
module PureGreeks
|
|
1737
|
+
module ImpliedVolatility
|
|
1738
|
+
module BrentSolver
|
|
1739
|
+
MAX_ITERATIONS = 100
|
|
1740
|
+
|
|
1741
|
+
module_function
|
|
1742
|
+
|
|
1743
|
+
def find_root(lower:, upper:, tolerance: 1e-8, &f)
|
|
1744
|
+
a = lower.to_f
|
|
1745
|
+
b = upper.to_f
|
|
1746
|
+
fa = f.call(a)
|
|
1747
|
+
fb = f.call(b)
|
|
1748
|
+
|
|
1749
|
+
raise IVConvergenceError, "root not bracketed: f(#{a})=#{fa}, f(#{b})=#{fb}" if fa * fb > 0
|
|
1750
|
+
|
|
1751
|
+
if fa.abs < fb.abs
|
|
1752
|
+
a, b = b, a
|
|
1753
|
+
fa, fb = fb, fa
|
|
1754
|
+
end
|
|
1755
|
+
|
|
1756
|
+
c = a
|
|
1757
|
+
fc = fa
|
|
1758
|
+
mflag = true
|
|
1759
|
+
d = nil
|
|
1760
|
+
|
|
1761
|
+
MAX_ITERATIONS.times do
|
|
1762
|
+
return b if fb.abs < tolerance || (b - a).abs < tolerance
|
|
1763
|
+
|
|
1764
|
+
s =
|
|
1765
|
+
if fa != fc && fb != fc
|
|
1766
|
+
# Inverse quadratic interpolation
|
|
1767
|
+
a * fb * fc / ((fa - fb) * (fa - fc)) +
|
|
1768
|
+
b * fa * fc / ((fb - fa) * (fb - fc)) +
|
|
1769
|
+
c * fa * fb / ((fc - fa) * (fc - fb))
|
|
1770
|
+
else
|
|
1771
|
+
# Secant method
|
|
1772
|
+
b - fb * (b - a) / (fb - fa)
|
|
1773
|
+
end
|
|
1774
|
+
|
|
1775
|
+
condition1 = !s.between?([(3 * a + b) / 4, b].min, [(3 * a + b) / 4, b].max)
|
|
1776
|
+
condition2 = mflag && (s - b).abs >= (b - c).abs / 2
|
|
1777
|
+
condition3 = !mflag && (s - b).abs >= (c - d).abs / 2
|
|
1778
|
+
condition4 = mflag && (b - c).abs < tolerance
|
|
1779
|
+
condition5 = !mflag && d && (c - d).abs < tolerance
|
|
1780
|
+
|
|
1781
|
+
if condition1 || condition2 || condition3 || condition4 || condition5
|
|
1782
|
+
s = (a + b) / 2.0
|
|
1783
|
+
mflag = true
|
|
1784
|
+
else
|
|
1785
|
+
mflag = false
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
fs = f.call(s)
|
|
1789
|
+
d = c
|
|
1790
|
+
c = b
|
|
1791
|
+
fc = fb
|
|
1792
|
+
|
|
1793
|
+
if fa * fs < 0
|
|
1794
|
+
b = s
|
|
1795
|
+
fb = fs
|
|
1796
|
+
else
|
|
1797
|
+
a = s
|
|
1798
|
+
fa = fs
|
|
1799
|
+
end
|
|
1800
|
+
|
|
1801
|
+
if fa.abs < fb.abs
|
|
1802
|
+
a, b = b, a
|
|
1803
|
+
fa, fb = fb, fa
|
|
1804
|
+
end
|
|
1805
|
+
end
|
|
1806
|
+
|
|
1807
|
+
raise IVConvergenceError, "exceeded #{MAX_ITERATIONS} iterations"
|
|
1808
|
+
end
|
|
1809
|
+
end
|
|
1810
|
+
end
|
|
1811
|
+
end
|
|
1812
|
+
```
|
|
1813
|
+
|
|
1814
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1815
|
+
|
|
1816
|
+
Run: `bundle exec rspec spec/implied_volatility/brent_solver_spec.rb`
|
|
1817
|
+
Expected: 3 examples, 0 failures.
|
|
1818
|
+
|
|
1819
|
+
- [ ] **Step 5: Commit**
|
|
1820
|
+
|
|
1821
|
+
```bash
|
|
1822
|
+
git add lib/pure_greeks/implied_volatility/brent_solver.rb spec/implied_volatility/brent_solver_spec.rb
|
|
1823
|
+
git commit -m "feat: add Brent's method root finder"
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
### Task 19: Implied volatility on Option
|
|
1827
|
+
|
|
1828
|
+
**Files:**
|
|
1829
|
+
- Modify: `lib/pure_greeks/option.rb`
|
|
1830
|
+
- Modify: `spec/option_spec.rb`
|
|
1831
|
+
|
|
1832
|
+
Inverts the BS European pricing function via Brent. (American IV solving via CRR is too slow for v0.1 — use BS European inversion as a close approximation. Document this limitation.)
|
|
1833
|
+
|
|
1834
|
+
- [ ] **Step 1: Write failing test**
|
|
1835
|
+
|
|
1836
|
+
Append to `spec/option_spec.rb`:
|
|
1837
|
+
|
|
1838
|
+
```ruby
|
|
1839
|
+
describe "#implied_volatility (when market_price given)" do
|
|
1840
|
+
let(:european_args) do
|
|
1841
|
+
{
|
|
1842
|
+
exercise_style: :european,
|
|
1843
|
+
type: :call,
|
|
1844
|
+
strike: 100.0,
|
|
1845
|
+
expiration: expiration,
|
|
1846
|
+
underlying_price: 100.0,
|
|
1847
|
+
market_price: 10.4506,
|
|
1848
|
+
risk_free_rate: 0.05,
|
|
1849
|
+
dividend_yield: 0.0,
|
|
1850
|
+
valuation_date: valuation_date
|
|
1851
|
+
}
|
|
1852
|
+
end
|
|
1853
|
+
|
|
1854
|
+
it "solves IV ≈ 0.20 for known Hull price" do
|
|
1855
|
+
option = described_class.new(**european_args)
|
|
1856
|
+
expect(option.implied_volatility).to be_within(1e-4).of(0.20)
|
|
1857
|
+
end
|
|
1858
|
+
|
|
1859
|
+
it "raises when market_price absent and no IV given" do
|
|
1860
|
+
args = european_args.dup
|
|
1861
|
+
args.delete(:market_price)
|
|
1862
|
+
option = described_class.new(**args)
|
|
1863
|
+
expect { option.implied_volatility }.to raise_error(PureGreeks::InvalidInputError)
|
|
1864
|
+
end
|
|
1865
|
+
end
|
|
1866
|
+
```
|
|
1867
|
+
|
|
1868
|
+
- [ ] **Step 2: Run, expect failure**
|
|
1869
|
+
|
|
1870
|
+
Run: `bundle exec rspec spec/option_spec.rb`
|
|
1871
|
+
Expected: NoMethodError on `implied_volatility` (currently only an attr).
|
|
1872
|
+
|
|
1873
|
+
- [ ] **Step 3: Implement IV solver on Option**
|
|
1874
|
+
|
|
1875
|
+
Modify `lib/pure_greeks/option.rb`. Add to top of file:
|
|
1876
|
+
|
|
1877
|
+
```ruby
|
|
1878
|
+
require "pure_greeks/engines/black_scholes_european"
|
|
1879
|
+
require "pure_greeks/implied_volatility/brent_solver"
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
Replace the `def implied_volatility` accessor (currently `attr_reader`) by removing it from the `attr_reader` line and adding:
|
|
1883
|
+
|
|
1884
|
+
```ruby
|
|
1885
|
+
def implied_volatility
|
|
1886
|
+
return @implied_volatility if @implied_volatility
|
|
1887
|
+
raise InvalidInputError, "market_price required to solve for implied_volatility" unless @market_price
|
|
1888
|
+
|
|
1889
|
+
@implied_volatility = ImpliedVolatility::BrentSolver.find_root(lower: 1e-6, upper: 5.0, tolerance: 1e-6) do |sigma|
|
|
1890
|
+
Engines::BlackScholesEuropean.price(
|
|
1891
|
+
type: @type,
|
|
1892
|
+
strike: @strike,
|
|
1893
|
+
underlying_price: @underlying_price,
|
|
1894
|
+
time_to_expiry: time_to_expiry,
|
|
1895
|
+
implied_volatility: sigma,
|
|
1896
|
+
risk_free_rate: @risk_free_rate,
|
|
1897
|
+
dividend_yield: @dividend_yield
|
|
1898
|
+
) - @market_price
|
|
1899
|
+
end
|
|
1900
|
+
end
|
|
1901
|
+
```
|
|
1902
|
+
|
|
1903
|
+
(Update the `attr_reader` line to remove `:implied_volatility`.)
|
|
1904
|
+
|
|
1905
|
+
- [ ] **Step 4: Run, expect pass**
|
|
1906
|
+
|
|
1907
|
+
Run: `bundle exec rspec spec/option_spec.rb`
|
|
1908
|
+
Expected: 13 examples, 0 failures.
|
|
1909
|
+
|
|
1910
|
+
- [ ] **Step 5: Commit**
|
|
1911
|
+
|
|
1912
|
+
```bash
|
|
1913
|
+
git add lib/pure_greeks/option.rb spec/option_spec.rb
|
|
1914
|
+
git commit -m "feat: add implied volatility solver via Brent's method"
|
|
1915
|
+
```
|
|
1916
|
+
|
|
1917
|
+
---
|
|
1918
|
+
|
|
1919
|
+
## Phase 7: Validation Against Tenor Golden Dataset
|
|
1920
|
+
|
|
1921
|
+
> **Prereq:** Tenor's `mcp__postgres-prod__query` MCP tool must be available in this session. The Tenor user memory at `~/.claude/projects/-Users-jravaliya-Code-tenor/memory/MEMORY.md` confirms read-only MCP access is the default. Per `feedback_prod_db_writes.md`, **only SELECT queries** — never any write.
|
|
1922
|
+
>
|
|
1923
|
+
> If MCP unavailable, skip this phase, leave `spec/regression/fixtures/tenor_golden.json` empty, and note in the README that golden-dataset validation is pending.
|
|
1924
|
+
|
|
1925
|
+
### Task 20: Export tool — pull golden data from Tenor prod DB
|
|
1926
|
+
|
|
1927
|
+
**Files:**
|
|
1928
|
+
- Create: `tools/golden_dataset_export.rb`
|
|
1929
|
+
- Create: `spec/regression/fixtures/tenor_golden.json`
|
|
1930
|
+
|
|
1931
|
+
The export script is **a one-shot manual run**, not part of the gem's runtime. It documents the SQL query used so future regenerations are reproducible.
|
|
1932
|
+
|
|
1933
|
+
**Connecting to Tenor's DB.** The connection string lives in `~/Code/tenor/.mcp.json` under `mcpServers.postgres-prod.args[2]`. Read it directly and pipe it into `psql`:
|
|
1934
|
+
|
|
1935
|
+
```bash
|
|
1936
|
+
PG_URL=$(jq -r '.mcpServers."postgres-prod".args[2]' ~/Code/tenor/.mcp.json)
|
|
1937
|
+
psql "$PG_URL" -c "\d options.snapshots" # smoke-test: should describe table
|
|
1938
|
+
```
|
|
1939
|
+
|
|
1940
|
+
This is read-only — only run `SELECT` statements. The MCP path (`mcp__postgres-prod__query`) is also fine if it's wired into the current session, but `psql` works with no MCP setup.
|
|
1941
|
+
|
|
1942
|
+
**Risk-free rate sourcing.** Before writing the export, run `\d options.snapshots` (and any related rate/config tables) to see whether Tenor stores the rate per-snapshot. Three cases:
|
|
1943
|
+
|
|
1944
|
+
1. **Stored per-snapshot** (most likely): include the rate column directly in the `SELECT` below. Done.
|
|
1945
|
+
2. **Stored as a constant in Tenor config**: read the constant from Tenor's source, embed it in every fixture row, document the value in the fixture's `_meta` block.
|
|
1946
|
+
3. **Computed at run-time from FRED**: pull the FRED CSV for the matching snapshot dates — `https://fred.stlouisfed.org/graph/fredgraph.csv?id=DGS3MO` (or whichever series Tenor uses; check Tenor's source). No FRED API key needed for the CSV endpoint. Embed per-row in the fixture.
|
|
1947
|
+
|
|
1948
|
+
The existing query below assumes case (1) and pulls a `risk_free_rate` column. Adjust if the schema differs.
|
|
1949
|
+
|
|
1950
|
+
- [ ] **Step 1: Write the export script**
|
|
1951
|
+
|
|
1952
|
+
Create `tools/golden_dataset_export.rb`:
|
|
1953
|
+
|
|
1954
|
+
```ruby
|
|
1955
|
+
# frozen_string_literal: true
|
|
1956
|
+
|
|
1957
|
+
# Manual one-shot tool to export a golden dataset from Tenor's prod DB.
|
|
1958
|
+
#
|
|
1959
|
+
# This script is not run as part of the gem; it documents the query and the
|
|
1960
|
+
# expected JSON shape. To regenerate the fixture, run the SQL below via
|
|
1961
|
+
# Tenor's mcp__postgres-prod__query MCP and pipe the output into
|
|
1962
|
+
# spec/regression/fixtures/tenor_golden.json.
|
|
1963
|
+
#
|
|
1964
|
+
# READ-ONLY. Never modify this query to a write operation.
|
|
1965
|
+
|
|
1966
|
+
GOLDEN_DATASET_QUERY = <<~SQL
|
|
1967
|
+
SELECT
|
|
1968
|
+
s.id AS snapshot_id,
|
|
1969
|
+
s.option_type,
|
|
1970
|
+
s.strike,
|
|
1971
|
+
s.expiration,
|
|
1972
|
+
s.underlying_price,
|
|
1973
|
+
s.implied_volatility,
|
|
1974
|
+
s.snapshot_date,
|
|
1975
|
+
COALESCE(s.dividend_yield, 0) AS dividend_yield,
|
|
1976
|
+
s.risk_free_rate, -- adjust if Tenor stores this elsewhere; see notes above Step 1
|
|
1977
|
+
g.delta,
|
|
1978
|
+
g.gamma,
|
|
1979
|
+
g.theta,
|
|
1980
|
+
g.vega,
|
|
1981
|
+
g.rho,
|
|
1982
|
+
g.calculated_price,
|
|
1983
|
+
g.calculation_model
|
|
1984
|
+
FROM options.greeks g
|
|
1985
|
+
JOIN options.snapshots s ON s.id = g.snapshot_id
|
|
1986
|
+
WHERE g.calculation_model IN ('quantlib_american', 'quantlib_european', 'intrinsic')
|
|
1987
|
+
AND s.implied_volatility IS NOT NULL
|
|
1988
|
+
AND s.implied_volatility > 0
|
|
1989
|
+
AND s.expiration > s.snapshot_date
|
|
1990
|
+
ORDER BY RANDOM()
|
|
1991
|
+
LIMIT 500;
|
|
1992
|
+
SQL
|
|
1993
|
+
|
|
1994
|
+
# If Tenor stores risk-free rate per-snapshot, the SELECT above pulls it
|
|
1995
|
+
# directly. If it's a constant or pulled from FRED, see the prose notes
|
|
1996
|
+
# above Step 1 for the FRED CSV fallback. Use this constant only as a
|
|
1997
|
+
# last resort.
|
|
1998
|
+
DEFAULT_RISK_FREE_RATE = 0.05
|
|
1999
|
+
|
|
2000
|
+
puts GOLDEN_DATASET_QUERY
|
|
2001
|
+
puts "(Pipe the result into JSON shaped like:)"
|
|
2002
|
+
puts <<~JSON
|
|
2003
|
+
[
|
|
2004
|
+
{
|
|
2005
|
+
"snapshot_id": "...",
|
|
2006
|
+
"option_type": "calls",
|
|
2007
|
+
"strike": 150.0,
|
|
2008
|
+
"expiration": "2026-06-19",
|
|
2009
|
+
"underlying_price": 148.5,
|
|
2010
|
+
"implied_volatility": 0.35,
|
|
2011
|
+
"snapshot_date": "2026-04-26",
|
|
2012
|
+
"dividend_yield": 0.0,
|
|
2013
|
+
"risk_free_rate": 0.05,
|
|
2014
|
+
"expected": {
|
|
2015
|
+
"delta": 0.42,
|
|
2016
|
+
"gamma": 0.018,
|
|
2017
|
+
"theta": -0.012,
|
|
2018
|
+
"vega": 0.31,
|
|
2019
|
+
"rho": 0.08,
|
|
2020
|
+
"calculated_price": 4.27,
|
|
2021
|
+
"calculation_model": "quantlib_american"
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
]
|
|
2025
|
+
JSON
|
|
2026
|
+
```
|
|
2027
|
+
|
|
2028
|
+
- [ ] **Step 2: Inspect schema and confirm rate sourcing**
|
|
2029
|
+
|
|
2030
|
+
```bash
|
|
2031
|
+
PG_URL=$(jq -r '.mcpServers."postgres-prod".args[2]' ~/Code/tenor/.mcp.json)
|
|
2032
|
+
psql "$PG_URL" -c "\d options.snapshots"
|
|
2033
|
+
psql "$PG_URL" -c "\d options.greeks"
|
|
2034
|
+
```
|
|
2035
|
+
|
|
2036
|
+
If `risk_free_rate` is on `options.snapshots`, proceed with the query as-is. If it's elsewhere, adjust the SELECT. If Tenor pulls live from FRED, switch to the FRED CSV approach and document the series ID in the fixture's `_meta`.
|
|
2037
|
+
|
|
2038
|
+
- [ ] **Step 3: Run the export**
|
|
2039
|
+
|
|
2040
|
+
```bash
|
|
2041
|
+
psql "$PG_URL" -A -F $'\t' --pset=footer=off -c "<contents of GOLDEN_DATASET_QUERY>" > /tmp/tenor_golden.tsv
|
|
2042
|
+
```
|
|
2043
|
+
|
|
2044
|
+
(Or use the MCP path `mcp__postgres-prod__query` if it's wired up — same result.)
|
|
2045
|
+
|
|
2046
|
+
- [ ] **Step 4: Write fixture**
|
|
2047
|
+
|
|
2048
|
+
Transform the TSV/MCP result into the JSON shape shown in step 1, save to `spec/regression/fixtures/tenor_golden.json`. Include a top-level `_meta` block recording the export date, source DB, and rate-sourcing decision (per-snapshot column / Tenor constant / FRED series ID).
|
|
2049
|
+
|
|
2050
|
+
- [ ] **Step 5: Commit**
|
|
2051
|
+
|
|
2052
|
+
```bash
|
|
2053
|
+
git add tools/golden_dataset_export.rb spec/regression/fixtures/tenor_golden.json
|
|
2054
|
+
git commit -m "feat: add Tenor golden dataset export tool and fixture"
|
|
2055
|
+
```
|
|
2056
|
+
|
|
2057
|
+
### Task 21: Regression suite
|
|
2058
|
+
|
|
2059
|
+
**Files:**
|
|
2060
|
+
- Create: `spec/regression/golden_dataset_spec.rb`
|
|
2061
|
+
|
|
2062
|
+
Compares PureGreeks output against QuantLib outputs from the fixture. Reports drift, fails on tolerance violation.
|
|
2063
|
+
|
|
2064
|
+
- [ ] **Step 1: Write the regression spec**
|
|
2065
|
+
|
|
2066
|
+
Create `spec/regression/golden_dataset_spec.rb`:
|
|
2067
|
+
|
|
2068
|
+
```ruby
|
|
2069
|
+
require "json"
|
|
2070
|
+
require "date"
|
|
2071
|
+
require "pure_greeks"
|
|
2072
|
+
|
|
2073
|
+
RSpec.describe "Regression against Tenor QuantLib golden dataset" do
|
|
2074
|
+
fixture_path = File.expand_path("fixtures/tenor_golden.json", __dir__)
|
|
2075
|
+
|
|
2076
|
+
if File.exist?(fixture_path)
|
|
2077
|
+
fixture = JSON.parse(File.read(fixture_path))
|
|
2078
|
+
|
|
2079
|
+
fixture.each do |row|
|
|
2080
|
+
describe "snapshot #{row['snapshot_id']}" do
|
|
2081
|
+
let(:expected) { row.fetch("expected") }
|
|
2082
|
+
let(:option) do
|
|
2083
|
+
PureGreeks::Option.new(
|
|
2084
|
+
exercise_style: :american,
|
|
2085
|
+
type: row["option_type"] == "puts" ? :put : :call,
|
|
2086
|
+
strike: row["strike"].to_f,
|
|
2087
|
+
expiration: Date.parse(row["expiration"]),
|
|
2088
|
+
underlying_price: row["underlying_price"].to_f,
|
|
2089
|
+
implied_volatility: row["implied_volatility"].to_f,
|
|
2090
|
+
risk_free_rate: row["risk_free_rate"].to_f,
|
|
2091
|
+
dividend_yield: row["dividend_yield"].to_f,
|
|
2092
|
+
valuation_date: Date.parse(row["snapshot_date"])
|
|
2093
|
+
)
|
|
2094
|
+
end
|
|
2095
|
+
|
|
2096
|
+
it "matches delta within 1e-3" do
|
|
2097
|
+
expect(option.delta).to be_within(1e-3).of(expected["delta"].to_f)
|
|
2098
|
+
end
|
|
2099
|
+
|
|
2100
|
+
it "matches gamma within 1e-4" do
|
|
2101
|
+
expect(option.gamma).to be_within(1e-4).of(expected["gamma"].to_f)
|
|
2102
|
+
end
|
|
2103
|
+
|
|
2104
|
+
it "matches theta within 1e-3" do
|
|
2105
|
+
expect(option.theta).to be_within(1e-3).of(expected["theta"].to_f)
|
|
2106
|
+
end
|
|
2107
|
+
|
|
2108
|
+
it "matches vega within 1e-3" do
|
|
2109
|
+
expect(option.vega).to be_within(1e-3).of(expected["vega"].to_f)
|
|
2110
|
+
end
|
|
2111
|
+
|
|
2112
|
+
it "matches rho within 5e-3" do
|
|
2113
|
+
expect(option.rho).to be_within(5e-3).of(expected["rho"].to_f)
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
it "matches price within 1e-2" do
|
|
2117
|
+
expect(option.price).to be_within(1e-2).of(expected["calculated_price"].to_f)
|
|
2118
|
+
end
|
|
2119
|
+
end
|
|
2120
|
+
end
|
|
2121
|
+
else
|
|
2122
|
+
it "skipped: golden fixture not present" do
|
|
2123
|
+
pending "spec/regression/fixtures/tenor_golden.json missing — run tools/golden_dataset_export.rb"
|
|
2124
|
+
raise
|
|
2125
|
+
end
|
|
2126
|
+
end
|
|
2127
|
+
end
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
- [ ] **Step 2: Run regression suite**
|
|
2131
|
+
|
|
2132
|
+
Run: `bundle exec rspec spec/regression/golden_dataset_spec.rb`
|
|
2133
|
+
|
|
2134
|
+
Two outcomes are acceptable:
|
|
2135
|
+
|
|
2136
|
+
**A) All pass** — celebrate, commit, move on.
|
|
2137
|
+
|
|
2138
|
+
**B) Some fail** — investigate. Tighten/loosen tolerances based on observed drift. Document drift in a `REGRESSION_REPORT.md` at the repo root with histograms (max/mean/std drift per Greek). The goal is `< 0.5%` of fixture rows failing — if more, debug the math.
|
|
2139
|
+
|
|
2140
|
+
- [ ] **Step 3: Iterate until passing**
|
|
2141
|
+
|
|
2142
|
+
If failures point to a math bug, fix the engine. If failures are extreme-IV edge cases that QuantLib also handles via fallback, ensure your fallback chain agrees.
|
|
2143
|
+
|
|
2144
|
+
- [ ] **Step 4: Commit**
|
|
2145
|
+
|
|
2146
|
+
```bash
|
|
2147
|
+
git add spec/regression/golden_dataset_spec.rb REGRESSION_REPORT.md
|
|
2148
|
+
git commit -m "test: add regression suite against Tenor QuantLib golden dataset"
|
|
2149
|
+
```
|
|
2150
|
+
|
|
2151
|
+
---
|
|
2152
|
+
|
|
2153
|
+
## Phase 8: Performance
|
|
2154
|
+
|
|
2155
|
+
### Task 22: Single-option microbenchmark
|
|
2156
|
+
|
|
2157
|
+
**Files:**
|
|
2158
|
+
- Create: `bench/single_option.rb`
|
|
2159
|
+
|
|
2160
|
+
- [ ] **Step 1: Write benchmark**
|
|
2161
|
+
|
|
2162
|
+
Create `bench/single_option.rb`:
|
|
2163
|
+
|
|
2164
|
+
```ruby
|
|
2165
|
+
require "benchmark/ips"
|
|
2166
|
+
require "pure_greeks"
|
|
2167
|
+
|
|
2168
|
+
option_args = {
|
|
2169
|
+
exercise_style: :american,
|
|
2170
|
+
type: :call,
|
|
2171
|
+
strike: 150.0,
|
|
2172
|
+
expiration: Date.new(2027, 4, 26),
|
|
2173
|
+
underlying_price: 148.5,
|
|
2174
|
+
implied_volatility: 0.35,
|
|
2175
|
+
risk_free_rate: 0.05,
|
|
2176
|
+
dividend_yield: 0.0,
|
|
2177
|
+
valuation_date: Date.new(2026, 4, 26)
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
Benchmark.ips do |x|
|
|
2181
|
+
x.report("American CRR (200 steps)") do
|
|
2182
|
+
PureGreeks::Option.new(**option_args).greeks
|
|
2183
|
+
end
|
|
2184
|
+
|
|
2185
|
+
x.report("European Black-Scholes") do
|
|
2186
|
+
PureGreeks::Option.new(**option_args.merge(exercise_style: :european)).greeks
|
|
2187
|
+
end
|
|
2188
|
+
end
|
|
2189
|
+
```
|
|
2190
|
+
|
|
2191
|
+
- [ ] **Step 2: Add benchmark-ips dev dep**
|
|
2192
|
+
|
|
2193
|
+
Edit `pure_greeks.gemspec`:
|
|
2194
|
+
|
|
2195
|
+
```ruby
|
|
2196
|
+
spec.add_development_dependency "benchmark-ips", "~> 2.13"
|
|
2197
|
+
```
|
|
2198
|
+
|
|
2199
|
+
Run: `bundle install`
|
|
2200
|
+
|
|
2201
|
+
- [ ] **Step 3: Run benchmark, record baseline**
|
|
2202
|
+
|
|
2203
|
+
Run: `bundle exec ruby bench/single_option.rb`
|
|
2204
|
+
|
|
2205
|
+
Expected output (rough order of magnitude — actual numbers depend on hardware):
|
|
2206
|
+
- BS European: ~10,000-50,000 ops/sec
|
|
2207
|
+
- CRR American: ~50-500 ops/sec (the 200-step tree dominates)
|
|
2208
|
+
|
|
2209
|
+
Save the output to `BENCHMARKS.md` at the repo root.
|
|
2210
|
+
|
|
2211
|
+
- [ ] **Step 4: Commit**
|
|
2212
|
+
|
|
2213
|
+
```bash
|
|
2214
|
+
git add bench/single_option.rb pure_greeks.gemspec Gemfile.lock BENCHMARKS.md
|
|
2215
|
+
git commit -m "perf: add single-option microbenchmark and baseline"
|
|
2216
|
+
```
|
|
2217
|
+
|
|
2218
|
+
### Task 23: Batch benchmark
|
|
2219
|
+
|
|
2220
|
+
**Files:**
|
|
2221
|
+
- Create: `bench/batch.rb`
|
|
2222
|
+
|
|
2223
|
+
- [ ] **Step 1: Write batch benchmark**
|
|
2224
|
+
|
|
2225
|
+
Create `bench/batch.rb`:
|
|
2226
|
+
|
|
2227
|
+
```ruby
|
|
2228
|
+
require "benchmark"
|
|
2229
|
+
require "pure_greeks"
|
|
2230
|
+
|
|
2231
|
+
base_args = {
|
|
2232
|
+
exercise_style: :american,
|
|
2233
|
+
strike: 150.0,
|
|
2234
|
+
expiration: Date.new(2027, 4, 26),
|
|
2235
|
+
underlying_price: 148.5,
|
|
2236
|
+
risk_free_rate: 0.05,
|
|
2237
|
+
dividend_yield: 0.0,
|
|
2238
|
+
valuation_date: Date.new(2026, 4, 26)
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
[100, 1_000, 10_000].each do |n|
|
|
2242
|
+
options = Array.new(n) do |i|
|
|
2243
|
+
PureGreeks::Option.new(
|
|
2244
|
+
type: i.even? ? :call : :put,
|
|
2245
|
+
implied_volatility: 0.20 + (i % 10) * 0.05,
|
|
2246
|
+
**base_args
|
|
2247
|
+
)
|
|
2248
|
+
end
|
|
2249
|
+
|
|
2250
|
+
elapsed = Benchmark.realtime do
|
|
2251
|
+
options.each(&:greeks)
|
|
2252
|
+
end
|
|
2253
|
+
|
|
2254
|
+
puts "#{n} options: #{elapsed.round(3)}s — #{(n / elapsed).round} ops/sec"
|
|
2255
|
+
end
|
|
2256
|
+
```
|
|
2257
|
+
|
|
2258
|
+
- [ ] **Step 2: Run, record results**
|
|
2259
|
+
|
|
2260
|
+
Run: `bundle exec ruby bench/batch.rb`
|
|
2261
|
+
|
|
2262
|
+
Append results to `BENCHMARKS.md`.
|
|
2263
|
+
|
|
2264
|
+
- [ ] **Step 3: Decision point — is performance acceptable?**
|
|
2265
|
+
|
|
2266
|
+
Compare to Tenor's QuantLib baseline. The Tenor `GreeksCalculationBatchJob` processes ~5,000-15,000 options per batch in ~30-60s (~250 ops/sec). If pure-Ruby PureGreeks hits ≥50% of that throughput (~125 ops/sec), ship v0.1 as pure Ruby.
|
|
2267
|
+
|
|
2268
|
+
If throughput < 50 ops/sec for American options, consider these in v0.2:
|
|
2269
|
+
1. Drop default `steps` from 200 → 100 (loses some accuracy in extreme cases — verify against golden dataset).
|
|
2270
|
+
2. Native C extension for the binomial backward induction loop (the inner `(0..i).each` is the hot path). Use `rice` gem.
|
|
2271
|
+
3. SIMD via `numo-narray` for vectorized backward induction.
|
|
2272
|
+
|
|
2273
|
+
Document the chosen path in `BENCHMARKS.md` under a "Path to v0.2" section.
|
|
2274
|
+
|
|
2275
|
+
- [ ] **Step 4: Commit**
|
|
2276
|
+
|
|
2277
|
+
```bash
|
|
2278
|
+
git add bench/batch.rb BENCHMARKS.md
|
|
2279
|
+
git commit -m "perf: add batch benchmark and v0.2 performance plan"
|
|
2280
|
+
```
|
|
2281
|
+
|
|
2282
|
+
---
|
|
2283
|
+
|
|
2284
|
+
## Phase 9: Documentation & Release
|
|
2285
|
+
|
|
2286
|
+
### Task 24: README (developer-focused)
|
|
2287
|
+
|
|
2288
|
+
**Files:**
|
|
2289
|
+
- Modify: `README.md`
|
|
2290
|
+
|
|
2291
|
+
The README is for developers who want to install, contribute to, or release the gem. End-user usage docs live on the GitHub Pages site (Task 27). The README should be short and scannable.
|
|
2292
|
+
|
|
2293
|
+
- [ ] **Step 1: Write README**
|
|
2294
|
+
|
|
2295
|
+
Replace `README.md` with:
|
|
2296
|
+
|
|
2297
|
+
```markdown
|
|
2298
|
+
# pure_greeks
|
|
2299
|
+
|
|
2300
|
+
[](https://rubygems.org/gems/pure_greeks)
|
|
2301
|
+
[](https://github.com/jayrav13/ruby-pure-greeks/actions/workflows/ci.yml)
|
|
2302
|
+
[](https://opensource.org/licenses/MIT)
|
|
2303
|
+
|
|
2304
|
+
Pure-Ruby options Greeks (delta, gamma, theta, vega, rho), pricing, and implied volatility for vanilla European and American options. No Python, no QuantLib system dep, no native code.
|
|
2305
|
+
|
|
2306
|
+
**Documentation, examples, and engine internals: https://jayrav13.github.io/ruby-pure-greeks/**
|
|
2307
|
+
|
|
2308
|
+
## Installation
|
|
2309
|
+
|
|
2310
|
+
Add to your Gemfile:
|
|
2311
|
+
|
|
2312
|
+
```ruby
|
|
2313
|
+
gem "pure_greeks"
|
|
2314
|
+
```
|
|
2315
|
+
|
|
2316
|
+
Then `bundle install`. Or install directly:
|
|
2317
|
+
|
|
2318
|
+
```bash
|
|
2319
|
+
gem install pure_greeks
|
|
2320
|
+
```
|
|
2321
|
+
|
|
2322
|
+
Requires Ruby 3.2 or newer. No system dependencies.
|
|
2323
|
+
|
|
2324
|
+
## Quick example
|
|
2325
|
+
|
|
2326
|
+
```ruby
|
|
2327
|
+
require "pure_greeks"
|
|
2328
|
+
|
|
2329
|
+
option = PureGreeks::Option.new(
|
|
2330
|
+
exercise_style: :american, type: :call,
|
|
2331
|
+
strike: 150.0, expiration: Date.new(2026, 6, 19),
|
|
2332
|
+
underlying_price: 148.5, implied_volatility: 0.35,
|
|
2333
|
+
risk_free_rate: 0.05, dividend_yield: 0.0,
|
|
2334
|
+
valuation_date: Date.today
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
option.price # => 4.27
|
|
2338
|
+
option.delta # => 0.42
|
|
2339
|
+
```
|
|
2340
|
+
|
|
2341
|
+
For the full API, the implied-volatility solver, how the three engines fall back to one another, validation methodology, and limitations, see the [documentation site](https://jayrav13.github.io/ruby-pure-greeks/).
|
|
2342
|
+
|
|
2343
|
+
## Development
|
|
2344
|
+
|
|
2345
|
+
Clone and bootstrap:
|
|
2346
|
+
|
|
2347
|
+
```bash
|
|
2348
|
+
git clone https://github.com/jayrav13/ruby-pure-greeks.git
|
|
2349
|
+
cd pure_greeks
|
|
2350
|
+
bin/setup
|
|
2351
|
+
```
|
|
2352
|
+
|
|
2353
|
+
Run the test suite:
|
|
2354
|
+
|
|
2355
|
+
```bash
|
|
2356
|
+
bundle exec rspec
|
|
2357
|
+
```
|
|
2358
|
+
|
|
2359
|
+
Run the linter:
|
|
2360
|
+
|
|
2361
|
+
```bash
|
|
2362
|
+
bundle exec rubocop
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
Open a console with the gem loaded:
|
|
2366
|
+
|
|
2367
|
+
```bash
|
|
2368
|
+
bin/console
|
|
2369
|
+
```
|
|
2370
|
+
|
|
2371
|
+
To install this gem onto your local machine for trial use:
|
|
2372
|
+
|
|
2373
|
+
```bash
|
|
2374
|
+
bundle exec rake install
|
|
2375
|
+
```
|
|
2376
|
+
|
|
2377
|
+
## Releasing
|
|
2378
|
+
|
|
2379
|
+
Releases are tag-driven through CI — no manual `gem push` needed.
|
|
2380
|
+
|
|
2381
|
+
1. On a feature branch, bump `lib/pure_greeks/version.rb` to the new version.
|
|
2382
|
+
2. Add a section to `CHANGELOG.md` for the new version (Keep-a-Changelog format).
|
|
2383
|
+
3. Open a PR; CI must be green.
|
|
2384
|
+
4. Merge to `main`. The release workflow (`.github/workflows/release.yml`) detects the version bump, runs the test suite, builds the gem, publishes to RubyGems via [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (no API key), creates a `vX.Y.Z` git tag, and opens a GitHub Release with auto-generated notes from the merged PRs.
|
|
2385
|
+
|
|
2386
|
+
The RubyGems version badge above refreshes automatically once the new version indexes on rubygems.org (usually within a minute).
|
|
2387
|
+
|
|
2388
|
+
## Contributing
|
|
2389
|
+
|
|
2390
|
+
Bug reports and pull requests are welcome at https://github.com/jayrav13/ruby-pure-greeks. Please run `bundle exec rspec` and `bundle exec rubocop` locally before opening a PR. CI runs both on Ruby 3.2, 3.3, and 3.4.
|
|
2391
|
+
|
|
2392
|
+
## License
|
|
2393
|
+
|
|
2394
|
+
MIT. See `LICENSE.txt`.
|
|
2395
|
+
```
|
|
2396
|
+
|
|
2397
|
+
- [ ] **Step 2: Commit**
|
|
2398
|
+
|
|
2399
|
+
```bash
|
|
2400
|
+
git add README.md
|
|
2401
|
+
git commit -m "docs: dev-focused README with badges, link to docs site"
|
|
2402
|
+
```
|
|
2403
|
+
|
|
2404
|
+
### Task 25: CHANGELOG
|
|
2405
|
+
|
|
2406
|
+
**Files:**
|
|
2407
|
+
- Create: `CHANGELOG.md`
|
|
2408
|
+
|
|
2409
|
+
- [ ] **Step 1: Write CHANGELOG**
|
|
2410
|
+
|
|
2411
|
+
Create `CHANGELOG.md`:
|
|
2412
|
+
|
|
2413
|
+
```markdown
|
|
2414
|
+
# Changelog
|
|
2415
|
+
|
|
2416
|
+
## [0.1.0] - YYYY-MM-DD
|
|
2417
|
+
|
|
2418
|
+
Initial release.
|
|
2419
|
+
|
|
2420
|
+
- Object-oriented `PureGreeks::Option` API for vanilla American/European options.
|
|
2421
|
+
- Three engines: CRR Binomial American (200 steps), Black-Scholes European (analytic), Intrinsic.
|
|
2422
|
+
- Automatic engine selection with fallback chain.
|
|
2423
|
+
- Implied volatility solver via Brent's method.
|
|
2424
|
+
- Regression-validated against QuantLib outputs from production options data.
|
|
2425
|
+
```
|
|
2426
|
+
|
|
2427
|
+
(Replace `YYYY-MM-DD` with the actual release date.)
|
|
2428
|
+
|
|
2429
|
+
- [ ] **Step 2: Commit**
|
|
2430
|
+
|
|
2431
|
+
```bash
|
|
2432
|
+
git add CHANGELOG.md
|
|
2433
|
+
git commit -m "docs: add CHANGELOG"
|
|
2434
|
+
```
|
|
2435
|
+
|
|
2436
|
+
### Task 26: GitHub Actions CI
|
|
2437
|
+
|
|
2438
|
+
**Files:**
|
|
2439
|
+
- Modify: `.github/workflows/ci.yml` (already exists from Task 1, currently the bundler default)
|
|
2440
|
+
|
|
2441
|
+
Restructure CI into two parallel jobs — `lint` and `test` — and add a rubocop cache so reruns are fast. This pattern mirrors `~/Code/njtransit/.github/workflows/ci.yml`.
|
|
2442
|
+
|
|
2443
|
+
- [ ] **Step 1: Replace `.github/workflows/ci.yml`**
|
|
2444
|
+
|
|
2445
|
+
```yaml
|
|
2446
|
+
name: CI
|
|
2447
|
+
|
|
2448
|
+
on:
|
|
2449
|
+
pull_request:
|
|
2450
|
+
push:
|
|
2451
|
+
branches: [main]
|
|
2452
|
+
|
|
2453
|
+
jobs:
|
|
2454
|
+
lint:
|
|
2455
|
+
runs-on: ubuntu-latest
|
|
2456
|
+
env:
|
|
2457
|
+
RUBOCOP_CACHE_ROOT: tmp/rubocop
|
|
2458
|
+
steps:
|
|
2459
|
+
- name: Checkout code
|
|
2460
|
+
uses: actions/checkout@v6
|
|
2461
|
+
|
|
2462
|
+
- name: Set up Ruby
|
|
2463
|
+
uses: ruby/setup-ruby@v1
|
|
2464
|
+
with:
|
|
2465
|
+
bundler-cache: true
|
|
2466
|
+
|
|
2467
|
+
- name: Prepare RuboCop cache
|
|
2468
|
+
uses: actions/cache@v5
|
|
2469
|
+
env:
|
|
2470
|
+
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', 'Gemfile.lock') }}
|
|
2471
|
+
with:
|
|
2472
|
+
path: ${{ env.RUBOCOP_CACHE_ROOT }}
|
|
2473
|
+
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
|
|
2474
|
+
restore-keys: |
|
|
2475
|
+
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
|
|
2476
|
+
|
|
2477
|
+
- name: Lint code for consistent style
|
|
2478
|
+
run: bundle exec rubocop --parallel -f github
|
|
2479
|
+
|
|
2480
|
+
test:
|
|
2481
|
+
runs-on: ubuntu-latest
|
|
2482
|
+
strategy:
|
|
2483
|
+
matrix:
|
|
2484
|
+
ruby-version: ["3.2", "3.3", "3.4"]
|
|
2485
|
+
steps:
|
|
2486
|
+
- name: Checkout code
|
|
2487
|
+
uses: actions/checkout@v6
|
|
2488
|
+
|
|
2489
|
+
- name: Set up Ruby ${{ matrix.ruby-version }}
|
|
2490
|
+
uses: ruby/setup-ruby@v1
|
|
2491
|
+
with:
|
|
2492
|
+
ruby-version: ${{ matrix.ruby-version }}
|
|
2493
|
+
bundler-cache: true
|
|
2494
|
+
|
|
2495
|
+
- name: Run RSpec
|
|
2496
|
+
run: bundle exec rspec
|
|
2497
|
+
```
|
|
2498
|
+
|
|
2499
|
+
- [ ] **Step 2: Commit**
|
|
2500
|
+
|
|
2501
|
+
```bash
|
|
2502
|
+
git add .github/workflows/ci.yml
|
|
2503
|
+
git commit -m "ci: split lint/test jobs, add rubocop cache, matrix Ruby 3.2-3.4"
|
|
2504
|
+
```
|
|
2505
|
+
|
|
2506
|
+
### Task 27: GitHub Pages documentation site
|
|
2507
|
+
|
|
2508
|
+
The README only covers dev setup. End-user usage docs — the API surface, examples, how the three engines fall back, validation methodology, and limitations — live on a GitHub Pages site served from `docs/` on `main`. We use a stock Jekyll theme (`cayman`) so there is no toolchain to maintain locally; GitHub builds the site on push.
|
|
2509
|
+
|
|
2510
|
+
**Files:**
|
|
2511
|
+
- Create: `docs/_config.yml`
|
|
2512
|
+
- Create: `docs/index.md`
|
|
2513
|
+
- Create: `docs/usage.md`
|
|
2514
|
+
- Create: `docs/engines.md`
|
|
2515
|
+
- Create: `docs/validation.md`
|
|
2516
|
+
- Create: `docs/limitations.md`
|
|
2517
|
+
|
|
2518
|
+
- [ ] **Step 1: Create `docs/_config.yml`**
|
|
2519
|
+
|
|
2520
|
+
```yaml
|
|
2521
|
+
title: pure_greeks
|
|
2522
|
+
description: Pure-Ruby options Greeks, pricing, and implied volatility — no QuantLib, no native code.
|
|
2523
|
+
theme: jekyll-theme-cayman
|
|
2524
|
+
|
|
2525
|
+
# These two are surfaced by the cayman theme as header buttons.
|
|
2526
|
+
github:
|
|
2527
|
+
repository_url: https://github.com/jayrav13/ruby-pure-greeks
|
|
2528
|
+
zip_url: https://github.com/jayrav13/ruby-pure-greeks/archive/refs/heads/main.zip
|
|
2529
|
+
|
|
2530
|
+
# Don't try to render fixtures or specs if anyone copies them in here later.
|
|
2531
|
+
exclude:
|
|
2532
|
+
- "*.gem"
|
|
2533
|
+
- Gemfile
|
|
2534
|
+
- Gemfile.lock
|
|
2535
|
+
```
|
|
2536
|
+
|
|
2537
|
+
- [ ] **Step 2: Create `docs/index.md`**
|
|
2538
|
+
|
|
2539
|
+
This is the landing page. Keep it tight; it should funnel readers to the right sub-page.
|
|
2540
|
+
|
|
2541
|
+
```markdown
|
|
2542
|
+
---
|
|
2543
|
+
title: pure_greeks
|
|
2544
|
+
---
|
|
2545
|
+
|
|
2546
|
+
# pure_greeks
|
|
2547
|
+
|
|
2548
|
+
Pure-Ruby options Greeks (delta, gamma, theta, vega, rho), pricing, and implied volatility for vanilla European and American options. No Python dependency, no QuantLib system install, no native code.
|
|
2549
|
+
|
|
2550
|
+
```ruby
|
|
2551
|
+
gem "pure_greeks"
|
|
2552
|
+
```
|
|
2553
|
+
|
|
2554
|
+
## Where to go next
|
|
2555
|
+
|
|
2556
|
+
- **[Usage](usage.html)** — full API reference with worked examples for pricing, Greeks, and implied volatility.
|
|
2557
|
+
- **[How the engines work](engines.html)** — Black-Scholes, CRR binomial, intrinsic, and how the fallback chain selects between them.
|
|
2558
|
+
- **[Validation](validation.html)** — methodology and tolerances for regression-testing against QuantLib output.
|
|
2559
|
+
- **[Limitations](limitations.html)** — what v0.1 does not cover and why.
|
|
2560
|
+
|
|
2561
|
+
## Source & releases
|
|
2562
|
+
|
|
2563
|
+
[GitHub repository](https://github.com/jayrav13/ruby-pure-greeks) · [RubyGems](https://rubygems.org/gems/pure_greeks) · [Changelog](https://github.com/jayrav13/ruby-pure-greeks/blob/main/CHANGELOG.md)
|
|
2564
|
+
```
|
|
2565
|
+
|
|
2566
|
+
- [ ] **Step 3: Create `docs/usage.md`**
|
|
2567
|
+
|
|
2568
|
+
This page absorbs the usage examples that previously lived in the README. Move them verbatim and expand: include the IV-solver example, all five Greeks, the `calculation_model` accessor, and a note on each constructor argument.
|
|
2569
|
+
|
|
2570
|
+
```markdown
|
|
2571
|
+
---
|
|
2572
|
+
title: Usage
|
|
2573
|
+
---
|
|
2574
|
+
|
|
2575
|
+
# Usage
|
|
2576
|
+
|
|
2577
|
+
## Pricing and Greeks (American)
|
|
2578
|
+
|
|
2579
|
+
```ruby
|
|
2580
|
+
require "pure_greeks"
|
|
2581
|
+
|
|
2582
|
+
option = PureGreeks::Option.new(
|
|
2583
|
+
exercise_style: :american,
|
|
2584
|
+
type: :call,
|
|
2585
|
+
strike: 150.0,
|
|
2586
|
+
expiration: Date.new(2026, 6, 19),
|
|
2587
|
+
underlying_price: 148.5,
|
|
2588
|
+
implied_volatility: 0.35,
|
|
2589
|
+
risk_free_rate: 0.05,
|
|
2590
|
+
dividend_yield: 0.0,
|
|
2591
|
+
valuation_date: Date.today
|
|
2592
|
+
)
|
|
2593
|
+
|
|
2594
|
+
option.price # => 4.27
|
|
2595
|
+
option.delta # => 0.42
|
|
2596
|
+
option.gamma # => 0.018
|
|
2597
|
+
option.theta # => -0.012 (per calendar day)
|
|
2598
|
+
option.vega # => 0.31 (per 1% vol move)
|
|
2599
|
+
option.rho # => 0.08 (per 1% rate move)
|
|
2600
|
+
option.calculation_model # => :crr_binomial_american
|
|
2601
|
+
```
|
|
2602
|
+
|
|
2603
|
+
## Solving for implied volatility
|
|
2604
|
+
|
|
2605
|
+
Pass `market_price:` instead of `implied_volatility:`. The solver uses Brent's method.
|
|
2606
|
+
|
|
2607
|
+
```ruby
|
|
2608
|
+
option = PureGreeks::Option.new(
|
|
2609
|
+
exercise_style: :european,
|
|
2610
|
+
type: :call,
|
|
2611
|
+
strike: 150.0,
|
|
2612
|
+
expiration: Date.new(2026, 6, 19),
|
|
2613
|
+
underlying_price: 148.5,
|
|
2614
|
+
market_price: 5.20,
|
|
2615
|
+
risk_free_rate: 0.05,
|
|
2616
|
+
dividend_yield: 0.0,
|
|
2617
|
+
valuation_date: Date.today
|
|
2618
|
+
)
|
|
2619
|
+
|
|
2620
|
+
option.implied_volatility # => 0.342
|
|
2621
|
+
```
|
|
2622
|
+
|
|
2623
|
+
## Constructor arguments
|
|
2624
|
+
|
|
2625
|
+
| Argument | Type | Notes |
|
|
2626
|
+
|---|---|---|
|
|
2627
|
+
| `exercise_style` | `:american` or `:european` | Routes to CRR or Black-Scholes. |
|
|
2628
|
+
| `type` | `:call` or `:put` | |
|
|
2629
|
+
| `strike` | Numeric | |
|
|
2630
|
+
| `expiration` | `Date` | |
|
|
2631
|
+
| `underlying_price` | Numeric | |
|
|
2632
|
+
| `implied_volatility` | Numeric | Annualized, decimal (0.35 == 35%). Either this or `market_price`, not both. |
|
|
2633
|
+
| `market_price` | Numeric | Triggers the IV solver. Either this or `implied_volatility`, not both. |
|
|
2634
|
+
| `risk_free_rate` | Numeric | Annualized, decimal. |
|
|
2635
|
+
| `dividend_yield` | Numeric | Annualized, decimal. |
|
|
2636
|
+
| `valuation_date` | `Date` | Defaults to `Date.today` if omitted. |
|
|
2637
|
+
|
|
2638
|
+
(Document any additional accessors or behavior added during implementation — keep this table in sync with `lib/pure_greeks/option.rb`.)
|
|
2639
|
+
```
|
|
2640
|
+
|
|
2641
|
+
- [ ] **Step 4: Create `docs/engines.md`**
|
|
2642
|
+
|
|
2643
|
+
```markdown
|
|
2644
|
+
---
|
|
2645
|
+
title: How the engines work
|
|
2646
|
+
---
|
|
2647
|
+
|
|
2648
|
+
# How the engines work
|
|
2649
|
+
|
|
2650
|
+
`pure_greeks` ships three pricing engines and a deterministic fallback chain that picks one for each call.
|
|
2651
|
+
|
|
2652
|
+
## The three engines
|
|
2653
|
+
|
|
2654
|
+
1. **Black-Scholes European (closed-form)** — analytic formula for European exercise. Cheap, exact within the model.
|
|
2655
|
+
2. **CRR Binomial American** — Cox-Ross-Rubinstein binomial tree, 200 steps. Captures early-exercise premium for American options. Greeks are extracted from the tree (delta and gamma from t=0 nodes, theta from t=1 vs t=0, vega and rho via finite difference over re-priced trees).
|
|
2656
|
+
3. **Intrinsic value** — `max(S - K, 0)` for calls, `max(K - S, 0)` for puts. The terminal fallback when implied volatility is zero or negative.
|
|
2657
|
+
|
|
2658
|
+
## Fallback chain
|
|
2659
|
+
|
|
2660
|
+
Selection order is fixed and deterministic:
|
|
2661
|
+
|
|
2662
|
+
1. If `implied_volatility <= 0`, use **Intrinsic**.
|
|
2663
|
+
2. Else if `exercise_style == :american`, use **CRR Binomial American**.
|
|
2664
|
+
3. Else use **Black-Scholes European**.
|
|
2665
|
+
|
|
2666
|
+
The engine that produced the result is always exposed on the option:
|
|
2667
|
+
|
|
2668
|
+
```ruby
|
|
2669
|
+
option.calculation_model # => :crr_binomial_american | :black_scholes_european | :intrinsic
|
|
2670
|
+
```
|
|
2671
|
+
|
|
2672
|
+
## Why this exists
|
|
2673
|
+
|
|
2674
|
+
QuantLib is the industry-standard option pricer, but its Ruby binding is a binary dep that's painful in production: you need a system install, version pinning is fragile, and it's hard to deploy on serverless platforms. `pure_greeks` is a deliberately scoped subset — the vanilla American/European Greeks that most equity-option workloads actually need — implemented in pure Ruby so it installs anywhere `gem install` works.
|
|
2675
|
+
```
|
|
2676
|
+
|
|
2677
|
+
- [ ] **Step 5: Create `docs/validation.md`**
|
|
2678
|
+
|
|
2679
|
+
```markdown
|
|
2680
|
+
---
|
|
2681
|
+
title: Validation
|
|
2682
|
+
---
|
|
2683
|
+
|
|
2684
|
+
# Validation
|
|
2685
|
+
|
|
2686
|
+
The engines have been regression-tested against a frozen dataset of ~500 historical option snapshots whose Greeks were computed by QuantLib (CRR Binomial American, 200 steps). The fixture and the script that generates it live in `spec/regression/`.
|
|
2687
|
+
|
|
2688
|
+
## Tolerances
|
|
2689
|
+
|
|
2690
|
+
| Quantity | Absolute tolerance |
|
|
2691
|
+
|---|---|
|
|
2692
|
+
| Price | 1e-3 |
|
|
2693
|
+
| Delta | 1e-3 |
|
|
2694
|
+
| Gamma | 1e-4 |
|
|
2695
|
+
| Theta (per calendar day) | 1e-3 |
|
|
2696
|
+
| Vega (per 1% vol move) | 1e-3 |
|
|
2697
|
+
| Rho (per 1% rate move) | 1e-3 |
|
|
2698
|
+
|
|
2699
|
+
These tolerances are tighter than the noise floor of typical market data (bid-ask spread, last-trade staleness), so any drift large enough to matter for downstream analytics will fail CI.
|
|
2700
|
+
|
|
2701
|
+
## How to regenerate the fixture
|
|
2702
|
+
|
|
2703
|
+
The fixture is regenerated manually (not on every CI run) by the on-call engineer when the source dataset changes. See `spec/regression/export_tenor_golden.rb` for the SQL query and the expected output shape. The export tool is read-only against the source database.
|
|
2704
|
+
```
|
|
2705
|
+
|
|
2706
|
+
- [ ] **Step 6: Create `docs/limitations.md`**
|
|
2707
|
+
|
|
2708
|
+
```markdown
|
|
2709
|
+
---
|
|
2710
|
+
title: Limitations
|
|
2711
|
+
---
|
|
2712
|
+
|
|
2713
|
+
# Limitations
|
|
2714
|
+
|
|
2715
|
+
`pure_greeks` v0.1 is intentionally scoped. Things it does **not** do:
|
|
2716
|
+
|
|
2717
|
+
- **Throughput.** Pure Ruby is roughly 10× slower than QuantLib's C++ for American options. Fine for interactive use and most batch jobs; see `BENCHMARKS.md` in the repo for measured numbers. A native extension is on the v0.2 backlog if real workloads need it.
|
|
2718
|
+
- **American implied volatility.** The IV solver inverts the Black-Scholes European pricer even for American options. For American options with significant early-exercise premium, the solved IV will be slightly off. v0.2 may add a CRR-based IV solver (slower but exact).
|
|
2719
|
+
- **Non-vanilla exercise.** No Bermudan, Asian, barrier, or any other exotic exercise style.
|
|
2720
|
+
- **Discrete dividends.** Dividend yield is treated as a continuous constant. Discrete dividends require a different tree and are out of scope for v0.1.
|
|
2721
|
+
- **Day-count conventions.** Time-to-expiry uses Actual/365 Fixed. If your reference data uses Actual/360 or 30/360, expect small drifts.
|
|
2722
|
+
|
|
2723
|
+
If any of these blocks your use case, please open an issue describing the workload — that drives v0.2 prioritization.
|
|
2724
|
+
```
|
|
2725
|
+
|
|
2726
|
+
- [ ] **Step 7: Enable Pages in repo settings**
|
|
2727
|
+
|
|
2728
|
+
GitHub Pages cannot be enabled from the local clone; it must be turned on once in the GitHub UI. Tell the user (don't attempt to do it from the CLI):
|
|
2729
|
+
|
|
2730
|
+
> Once the repo is on GitHub: **Settings → Pages → Source: Deploy from a branch → Branch: `main` / folder: `/docs` → Save.** First build takes ~1 minute. The site URL will be `https://<OWNER>.github.io/pure_greeks/`.
|
|
2731
|
+
|
|
2732
|
+
No GitHub Actions workflow is needed for this — Pages auto-builds when the source is `main /docs`.
|
|
2733
|
+
|
|
2734
|
+
- [ ] **Step 8: Commit**
|
|
2735
|
+
|
|
2736
|
+
```bash
|
|
2737
|
+
git add docs/
|
|
2738
|
+
git commit -m "docs: GitHub Pages site for usage, engines, validation, limitations"
|
|
2739
|
+
```
|
|
2740
|
+
|
|
2741
|
+
- [ ] **Step 9: Verify locally (optional)**
|
|
2742
|
+
|
|
2743
|
+
If the implementer wants to preview before pushing:
|
|
2744
|
+
|
|
2745
|
+
```bash
|
|
2746
|
+
gem install bundler jekyll
|
|
2747
|
+
cd docs && jekyll serve
|
|
2748
|
+
```
|
|
2749
|
+
|
|
2750
|
+
Otherwise, verification happens after push by visiting the Pages URL.
|
|
2751
|
+
|
|
2752
|
+
### Task 28: Release automation (RubyGems Trusted Publishing + GitHub Releases)
|
|
2753
|
+
|
|
2754
|
+
**Files:**
|
|
2755
|
+
- Create: `.github/workflows/release.yml`
|
|
2756
|
+
|
|
2757
|
+
The release flow is tag-driven by a version-file change on `main`. When `lib/pure_greeks/version.rb` changes, this workflow runs the test suite, builds the gem, publishes it to RubyGems via Trusted Publishing (OIDC — no API key in repo secrets), then creates a `vX.Y.Z` git tag and a GitHub Release with auto-generated notes from merged PRs. Mirrors `~/Code/njtransit/.github/workflows/release.yml`.
|
|
2758
|
+
|
|
2759
|
+
**Prerequisites the user has already handled** (per spike conversation):
|
|
2760
|
+
- Trusted Publishing is configured on rubygems.org for this gem.
|
|
2761
|
+
- A GitHub Environment named `rubygems` exists in the repo and is wired to the trusted publisher binding.
|
|
2762
|
+
|
|
2763
|
+
If either is not in place when this task runs, stop and surface that to the user — the workflow will fail-closed without them.
|
|
2764
|
+
|
|
2765
|
+
- [ ] **Step 1: Create `.github/workflows/release.yml`**
|
|
2766
|
+
|
|
2767
|
+
```yaml
|
|
2768
|
+
name: Release
|
|
2769
|
+
|
|
2770
|
+
on:
|
|
2771
|
+
push:
|
|
2772
|
+
branches: [main]
|
|
2773
|
+
paths:
|
|
2774
|
+
- "lib/pure_greeks/version.rb"
|
|
2775
|
+
|
|
2776
|
+
jobs:
|
|
2777
|
+
release:
|
|
2778
|
+
runs-on: ubuntu-latest
|
|
2779
|
+
environment: rubygems
|
|
2780
|
+
permissions:
|
|
2781
|
+
contents: write
|
|
2782
|
+
id-token: write
|
|
2783
|
+
|
|
2784
|
+
steps:
|
|
2785
|
+
- name: Checkout code
|
|
2786
|
+
uses: actions/checkout@v6
|
|
2787
|
+
|
|
2788
|
+
- name: Set up Ruby
|
|
2789
|
+
uses: ruby/setup-ruby@v1
|
|
2790
|
+
with:
|
|
2791
|
+
bundler-cache: true
|
|
2792
|
+
|
|
2793
|
+
- name: Run tests
|
|
2794
|
+
run: bundle exec rspec
|
|
2795
|
+
|
|
2796
|
+
- name: Run linter
|
|
2797
|
+
run: bundle exec rubocop --parallel -f github
|
|
2798
|
+
|
|
2799
|
+
- name: Extract version
|
|
2800
|
+
id: version
|
|
2801
|
+
run: |
|
|
2802
|
+
VERSION=$(ruby -r ./lib/pure_greeks/version -e 'puts PureGreeks::VERSION')
|
|
2803
|
+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
2804
|
+
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
|
2805
|
+
|
|
2806
|
+
- name: Build gem
|
|
2807
|
+
run: gem build pure_greeks.gemspec
|
|
2808
|
+
|
|
2809
|
+
- name: Configure Trusted Publishing credentials
|
|
2810
|
+
uses: rubygems/configure-rubygems-credentials@main
|
|
2811
|
+
|
|
2812
|
+
- name: Publish to RubyGems
|
|
2813
|
+
run: gem push pure_greeks-${{ steps.version.outputs.version }}.gem
|
|
2814
|
+
|
|
2815
|
+
- name: Create GitHub release
|
|
2816
|
+
env:
|
|
2817
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
2818
|
+
TAG: ${{ steps.version.outputs.tag }}
|
|
2819
|
+
GEM_FILE: pure_greeks-${{ steps.version.outputs.version }}.gem
|
|
2820
|
+
run: |
|
|
2821
|
+
gh release create "$TAG" \
|
|
2822
|
+
--title "$TAG" \
|
|
2823
|
+
--generate-notes \
|
|
2824
|
+
"$GEM_FILE"
|
|
2825
|
+
```
|
|
2826
|
+
|
|
2827
|
+
- [ ] **Step 2: Commit**
|
|
2828
|
+
|
|
2829
|
+
```bash
|
|
2830
|
+
git add .github/workflows/release.yml
|
|
2831
|
+
git commit -m "ci: add release workflow (RubyGems Trusted Publishing + GitHub Releases)"
|
|
2832
|
+
```
|
|
2833
|
+
|
|
2834
|
+
### Task 29: Cut v0.1.0 (CI does the publish)
|
|
2835
|
+
|
|
2836
|
+
**Files:**
|
|
2837
|
+
- Modify: `lib/pure_greeks/version.rb`
|
|
2838
|
+
- Modify: `CHANGELOG.md`
|
|
2839
|
+
|
|
2840
|
+
This task is the trigger for the release workflow built in Task 28. The publish itself happens automatically when the merge to `main` lands.
|
|
2841
|
+
|
|
2842
|
+
- [ ] **Step 1: Set version**
|
|
2843
|
+
|
|
2844
|
+
Replace `lib/pure_greeks/version.rb` contents:
|
|
2845
|
+
|
|
2846
|
+
```ruby
|
|
2847
|
+
# frozen_string_literal: true
|
|
2848
|
+
|
|
2849
|
+
module PureGreeks
|
|
2850
|
+
VERSION = "0.1.0"
|
|
2851
|
+
end
|
|
2852
|
+
```
|
|
2853
|
+
|
|
2854
|
+
- [ ] **Step 2: Date the CHANGELOG entry**
|
|
2855
|
+
|
|
2856
|
+
In `CHANGELOG.md`, replace `[0.1.0] - YYYY-MM-DD` with the actual release date.
|
|
2857
|
+
|
|
2858
|
+
- [ ] **Step 3: Verify everything passes locally**
|
|
2859
|
+
|
|
2860
|
+
```bash
|
|
2861
|
+
bundle exec rspec
|
|
2862
|
+
bundle exec rubocop
|
|
2863
|
+
bundle exec rspec spec/regression
|
|
2864
|
+
```
|
|
2865
|
+
|
|
2866
|
+
All three must be clean.
|
|
2867
|
+
|
|
2868
|
+
- [ ] **Step 4: Open the release PR**
|
|
2869
|
+
|
|
2870
|
+
```bash
|
|
2871
|
+
git checkout -b release/v0.1.0
|
|
2872
|
+
git add lib/pure_greeks/version.rb CHANGELOG.md
|
|
2873
|
+
git commit -m "chore: release 0.1.0"
|
|
2874
|
+
git push -u origin release/v0.1.0
|
|
2875
|
+
gh pr create --title "Release v0.1.0" --body "Cuts v0.1.0. CI will publish to RubyGems and create the GitHub Release on merge."
|
|
2876
|
+
```
|
|
2877
|
+
|
|
2878
|
+
- [ ] **Step 5: Confirm with user before merging**
|
|
2879
|
+
|
|
2880
|
+
Do **not** merge the PR autonomously. Surface to the user: "Release PR is up. CI is green. Merge to publish v0.1.0 to RubyGems and create the GitHub Release?" and wait for explicit go-ahead.
|
|
2881
|
+
|
|
2882
|
+
- [ ] **Step 6: After merge — watch the release workflow**
|
|
2883
|
+
|
|
2884
|
+
Once merged, watch:
|
|
2885
|
+
|
|
2886
|
+
```bash
|
|
2887
|
+
gh run watch
|
|
2888
|
+
```
|
|
2889
|
+
|
|
2890
|
+
Expected outcome (~2-3 min):
|
|
2891
|
+
- `pure_greeks 0.1.0` is live at https://rubygems.org/gems/pure_greeks
|
|
2892
|
+
- A `v0.1.0` GitHub Release exists at https://github.com/jayrav13/ruby-pure-greeks/releases/tag/v0.1.0 with auto-generated notes
|
|
2893
|
+
- The RubyGems badge in the README starts resolving (may take an extra minute to index)
|
|
2894
|
+
|
|
2895
|
+
---
|
|
2896
|
+
|
|
2897
|
+
## Open Questions / Future Work
|
|
2898
|
+
|
|
2899
|
+
The implementing session should flag these to the user as they come up:
|
|
2900
|
+
|
|
2901
|
+
1. ~~**License**~~: resolved during Task 1 — MIT propagated to gemspec; `LICENSE.txt` still needs to be written before publish.
|
|
2902
|
+
2. ~~**GitHub repo**~~: resolved — repo lives at `github.com/jayrav13/ruby-pure-greeks`. Origin is wired up.
|
|
2903
|
+
3. **Risk-free rate source**: confirm what rate Tenor's QuantLib run used (likely a constant from config or per-snapshot from FRED). Make this explicit in the golden fixture's `_meta` block.
|
|
2904
|
+
4. **American IV solver**: v0.1 inverts BS European. v0.2 could add CRR-based IV (slower but exact for American). Document as a known limitation.
|
|
2905
|
+
5. **C extension trigger**: if Phase 8 benchmarks show < 50 ops/sec for American Greeks, queue a v0.2 task to write a `rice`-based binomial backward-induction extension. Don't write it in v0.1.
|
|
2906
|
+
6. **Dividend handling**: Tenor uses constant dividend yield. v0.2 could support discrete dividends (would require a different tree structure).
|
|
2907
|
+
7. **Day count convention**: this plan uses Actual/365 Fixed throughout (matching Tenor). Some markets use Actual/360 or 30/360. Document and consider parameterizing in v0.2.
|
|
2908
|
+
|
|
2909
|
+
---
|
|
2910
|
+
|
|
2911
|
+
## Self-Review Checklist (for the engineer executing this plan)
|
|
2912
|
+
|
|
2913
|
+
Before declaring v0.1.0 ready:
|
|
2914
|
+
|
|
2915
|
+
- [ ] All RSpec examples pass.
|
|
2916
|
+
- [ ] Rubocop passes.
|
|
2917
|
+
- [ ] Regression suite against `tenor_golden.json` passes (or drift report is documented and acceptable).
|
|
2918
|
+
- [ ] README "Quick example" snippet runs cleanly in `bundle exec irb`.
|
|
2919
|
+
- [ ] All `docs/*.md` usage examples run cleanly in `bundle exec irb`.
|
|
2920
|
+
- [ ] README badges all resolve to real targets (CI workflow exists, license link is valid; the RubyGems badge will 404 until publish — that's expected).
|
|
2921
|
+
- [ ] GitHub Pages source is configured to `main` / `/docs` and the site renders at `https://jayrav13.github.io/ruby-pure-greeks/`.
|
|
2922
|
+
- [ ] `LICENSE.txt` exists at repo root with MIT text (the README's MIT badge links to it).
|
|
2923
|
+
- [ ] `release.yml` workflow is in place and the `rubygems` GitHub Environment is wired to RubyGems Trusted Publishing.
|
|
2924
|
+
- [ ] Benchmarks recorded in `BENCHMARKS.md`.
|
|
2925
|
+
- [ ] Version bumped to `0.1.0` and `CHANGELOG.md` dated.
|
|
2926
|
+
- [ ] Release PR is open, CI green; user has explicitly approved the merge that triggers publish.
|
|
2927
|
+
- [ ] After merge: gem live on RubyGems, GitHub Release `v0.1.0` created with notes.
|