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.
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
+ [![Gem Version](https://badge.fury.io/rb/pure_greeks.svg)](https://rubygems.org/gems/pure_greeks)
2301
+ [![CI](https://github.com/jayrav13/ruby-pure-greeks/actions/workflows/ci.yml/badge.svg)](https://github.com/jayrav13/ruby-pure-greeks/actions/workflows/ci.yml)
2302
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.