From 14ca99d743dede6db9d7100185d46e615572e74f Mon Sep 17 00:00:00 2001 From: Max Zhang Date: Sat, 6 Jun 2026 00:02:57 -0400 Subject: [PATCH 1/2] feat(american-option): log-spot Dynamic Chebyshev variant (Stage F1 front-fixing) Add a default-off LogSpot build option that interpolates the continuation in x = log(S) instead of linear S. The GBM transition is additive in x, so the Gauss-Hermite images stay bounded, and the narrow uniform-in-x grid is far better conditioned than the wide linear [5,250] grid at high node counts. Two measured wins vs the linear grid (StandardPut, oracle Gamma(82)=0.033689): (1) Robustness - log-spot stays finite at n=321 where the linear build throws 'function returned non-finite values', and at n=161 the linear spot-82 row wrongly collapses into the exercise region (Gamma 0) while log-spot gives 0.027767. (2) Accuracy - the spot-82 Gamma error converges monotonically with n (0.009807 -> 0.005922 -> 0.002755), more than halving it, so the boundary Gamma error was resolution-limited, not intrinsic. At low n=81 log-spot is marginally behind linear near the boundary, where its nodes cluster at the domain ends. Greeks use the chain rule from u(x)=V(e^x): Delta = u'(x)/S, Gamma = (u''-u')/S^2 (the -u' term is required). The OFF path is bit-identical (additive branch; existing linear code verbatim in the else arm). This recovers most of the boundary Gamma without the heavyweight boundary-tracking (Landau) re-architecture, now optional. Docs: case-study subsection 'A log-spot variant recovers the boundary Gamma'. Tests: OFF bit-identical lock; log-spot ATM price vs oracle; chain-rule Greeks vs linear baseline; finite-at-high-n where linear throws; spot-82 Gamma convergence measurement. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/american-option-dynamic-chebyshev.md | 19 +++ .../DynamicChebyshev.cs | 134 ++++++++++++++-- .../Finance/AmericanOptionLogSpotTests.cs | 148 ++++++++++++++++++ 3 files changed, 284 insertions(+), 17 deletions(-) create mode 100644 tests/ChebyshevSharp.Tests/Finance/AmericanOptionLogSpotTests.cs diff --git a/docs/docs/american-option-dynamic-chebyshev.md b/docs/docs/american-option-dynamic-chebyshev.md index 2ef6b97..8032f7e 100644 --- a/docs/docs/american-option-dynamic-chebyshev.md +++ b/docs/docs/american-option-dynamic-chebyshev.md @@ -821,6 +821,25 @@ endpoint sitting on the query (Company, Egorova & Jodar 2014). That is a solver re-architecture rather than a knot tweak, and is the direction for a future boundary-aware variant. +### A log-spot variant recovers the boundary Gamma + +The first step of that front-fixing direction is already informative. Interpolating the continuation in +`x = log(S)` (a `LogSpot` build option) makes the Gauss–Hermite transition additive, so the images stay +bounded, and the grid narrow and uniform in `x`, hence far better conditioned than the wide linear +`[5, 250]` grid. Measured spot-82 Gamma error against the oracle (`0.033689`): + +| nodes `n` | linear Γ(82) error | log-spot Γ(82) error | +| ---: | ---: | ---: | +| 81 | 0.007993 | 0.009807 | +| 161 | 0.033689 (linear: spot 82 collapses into the exercise region) | 0.005922 | +| 321 | non-finite (linear build throws) | 0.002755 | + +The log-spot error **falls monotonically with `n`** — the boundary Gamma was resolution-limited, not +intrinsic — and the log-spot build stays finite at node counts where the linear grid throws. At low `n` +the log-spot nodes cluster at the domain ends rather than at the boundary, so it trails the linear grid +there; the gain appears once the grid is refined, and where the linear grid can no longer run. This +recovers most of the boundary Gamma without the full boundary-tracking (Landau) re-architecture. + ## Accuracy and Speed The aggregate comparison is useful only after reading the per-candidate diff --git a/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs b/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs index 89c820f..230e8b2 100644 --- a/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs +++ b/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs @@ -17,7 +17,12 @@ public sealed record DynamicChebyshevSettings( // Chebyshev piece edge (it yields a non-physical negative Gamma one tick above the knot). Kept, // default-off, for reproducibility; superseded by the planned front-fixing variant. See the // "Why the boundary Gamma is weak" section of the American-option case study. - bool BoundarySplit = false); + bool BoundarySplit = false, + // Stage F1 front-fixing: interpolate the continuation in x = log(S) instead of linear S. The GBM + // transition is additive in x, so the Gauss-Hermite images stay bounded; the narrow uniform-in-x + // grid is also far better conditioned at high node counts, curing the high-n non-finite build + // failure seen on the wide linear [5,250] grid. Default off; the OFF path is bit-identical. + bool LogSpot = false); public sealed record DynamicChebyshevResult( double Price, @@ -156,7 +161,11 @@ public DynamicChebyshevAmericanOptionModel Build( var stopwatch = Stopwatch.StartNew(); int buildEvaluations = 0; - Func nextValue = spot => Payoff(request, spot); + // The terminal next-step value is the payoff. In the log-spot frame the value functions are + // indexed by x = log(S), so the seed and every step are expressed in x; otherwise in S. + Func nextValue = settings.LogSpot + ? x => Payoff(request, Math.Exp(x)) + : spot => Payoff(request, spot); ChebyshevApproximation? firstContinuation = null; Func? firstContinuationFunction = null; @@ -166,20 +175,41 @@ public DynamicChebyshevAmericanOptionModel Build( bool closedFormTerminal = settings.ClosedFormTerminalStep && step == settings.ExerciseSteps - 1 && request.Right == VanillaOptionRight.Put; - Func continuationFunction = closedFormTerminal - ? spot => EuropeanPutPrice( - spot, request.Strike, dt, request.RiskFreeRate, request.Volatility, request.DividendYield) - : spot => ContinuationValue(spot, drift, diffusion, discount, valueAtNextStep); - ChebyshevApproximation continuation = BuildContinuationApproximation( - request, - settings, - continuationFunction, - maxDerivativeOrder: 2); - - buildEvaluations += continuation.NEvaluations; - nextValue = spot => Math.Max( - Payoff(request, spot), - EvaluateApproximation(continuation, spot, settings, derivativeOrder: 0)); + + ChebyshevApproximation continuation; + Func continuationFunction; + if (settings.LogSpot) + { + // x = log(S): additive (bounded) transition, e^x payoff; the recursion runs in x. + continuationFunction = closedFormTerminal + ? x => EuropeanPutPrice( + Math.Exp(x), request.Strike, dt, request.RiskFreeRate, request.Volatility, request.DividendYield) + : x => LogSpotContinuationValue(x, drift, diffusion, discount, valueAtNextStep); + continuation = BuildLogSpotContinuationApproximation( + settings, continuationFunction, maxDerivativeOrder: 2); + + buildEvaluations += continuation.NEvaluations; + nextValue = x => Math.Max( + Payoff(request, Math.Exp(x)), + EvaluateLogSpotApproximation(continuation, x, settings, derivativeOrder: 0)); + } + else + { + continuationFunction = closedFormTerminal + ? spot => EuropeanPutPrice( + spot, request.Strike, dt, request.RiskFreeRate, request.Volatility, request.DividendYield) + : spot => ContinuationValue(spot, drift, diffusion, discount, valueAtNextStep); + continuation = BuildContinuationApproximation( + request, + settings, + continuationFunction, + maxDerivativeOrder: 2); + + buildEvaluations += continuation.NEvaluations; + nextValue = spot => Math.Max( + Payoff(request, spot), + EvaluateApproximation(continuation, spot, settings, derivativeOrder: 0)); + } if (step == 0) { @@ -196,7 +226,31 @@ public DynamicChebyshevAmericanOptionModel Build( Func firstFunc = firstContinuationFunction!; Func continuationCurve; - if (settings.BoundarySplit && request.Right == VanillaOptionRight.Put) + if (settings.LogSpot) + { + // The first continuation is interpolated in x = log(S). Convert S->x once, read the + // spectral x-derivatives, and apply the chain rule: Delta = u'(x)/S, Gamma = (u''-u')/S^2. + ChebyshevApproximation firstLog = firstApprox; + continuationCurve = (spot, order) => + { + double x = Math.Log(spot); + double u0 = EvaluateLogSpotApproximation(firstLog, x, settings, derivativeOrder: 0); + if (order == 0) + { + return u0; + } + + double u1 = EvaluateLogSpotApproximation(firstLog, x, settings, derivativeOrder: 1); + if (order == 1) + { + return u1 / spot; + } + + double u2 = EvaluateLogSpotApproximation(firstLog, x, settings, derivativeOrder: 2); + return (u2 - u1) / (spot * spot); + }; + } + else if (settings.BoundarySplit && request.Right == VanillaOptionRight.Put) { double GlobalContinuation(double spot) => EvaluateApproximation(firstApprox, spot, settings, derivativeOrder: 0); @@ -266,6 +320,52 @@ internal static double EvaluateApproximation( return approximation.VectorizedEval([clamped], [derivativeOrder]); } + private static ChebyshevApproximation BuildLogSpotContinuationApproximation( + DynamicChebyshevSettings settings, + Func continuation, + int maxDerivativeOrder) + { + // point[0] is x = log(S); the continuation function is already expressed in x. + double Function(double[] point, object? _) => continuation(point[0]); + + var approximation = new ChebyshevApproximation( + Function, + numDimensions: 1, + domain: [[Math.Log(settings.SpotLower), Math.Log(settings.SpotUpper)]], + nNodes: [settings.SpotNodeCount], + maxDerivativeOrder: maxDerivativeOrder); + approximation.Build(verbose: false); + return approximation; + } + + private static double LogSpotContinuationValue( + double x, + double drift, + double diffusion, + double discount, + Func nextValue) + { + double sum = 0.0; + for (int i = 0; i < HermiteNodes8.Length; i++) + { + // Additive in x (bounded), unlike the multiplicative linear image spot * exp(...). + double nextX = x + drift + Math.Sqrt(2.0) * diffusion * HermiteNodes8[i]; + sum += HermiteWeights8[i] * nextValue(nextX); + } + + return discount * sum / Math.Sqrt(Math.PI); + } + + internal static double EvaluateLogSpotApproximation( + ChebyshevApproximation approximation, + double x, + DynamicChebyshevSettings settings, + int derivativeOrder) + { + double clamped = Math.Clamp(x, Math.Log(settings.SpotLower), Math.Log(settings.SpotUpper)); + return approximation.VectorizedEval([clamped], [derivativeOrder]); + } + internal static double Payoff(AmericanOptionRequest request, double spot) { return request.Right switch diff --git a/tests/ChebyshevSharp.Tests/Finance/AmericanOptionLogSpotTests.cs b/tests/ChebyshevSharp.Tests/Finance/AmericanOptionLogSpotTests.cs new file mode 100644 index 0000000..7308a52 --- /dev/null +++ b/tests/ChebyshevSharp.Tests/Finance/AmericanOptionLogSpotTests.cs @@ -0,0 +1,148 @@ +using AmericanOptionDynamicChebyshev; +using Xunit.Abstractions; + +namespace ChebyshevSharp.Tests.Finance; + +/// +/// F1 — log-spot Dynamic Chebyshev variant (Stage F1 of the front-fixing track). Interpolating the +/// continuation in x = log(S) makes the Gauss-Hermite transition additive (bounded images) and the grid +/// narrow + uniform-in-x, which is far better conditioned at high node counts than the wide linear +/// [5,250] grid. Greeks come from the chain rule: Delta = u'(x)/S, Gamma = (u''(x) - u'(x)) / S^2. +/// +public sealed class AmericanOptionLogSpotTests +{ + private readonly ITestOutputHelper _output; + + public AmericanOptionLogSpotTests(ITestOutputHelper output) => _output = output; + + private static DynamicChebyshevSettings BaseSettings(int nodes = 81) => + new(ExerciseSteps: 80, SpotNodeCount: nodes, SpotLower: 5.0, SpotUpper: 250.0, QuadratureOrder: 8); + + [Fact] + public void LogSpot_off_path_is_bit_identical_to_today() + { + AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); + var pricer = new DynamicChebyshevAmericanOptionPricer(); + + DynamicChebyshevAmericanOptionModel off = + pricer.Build(request, BaseSettings() with { LogSpot = false }); + + // OFF path must reproduce the documented linear Dynamic Chebyshev anchors (regression lock). + DynamicChebyshevEvaluation atm = off.Evaluate(100.0); + Assert.InRange(Math.Abs(atm.Price - 6.083607), 0.0, 1e-5); + Assert.InRange(Math.Abs(atm.Delta - (-0.410533)), 0.0, 1e-5); + Assert.InRange(Math.Abs(atm.Gamma - 0.022946), 0.0, 1e-5); + Assert.InRange(Math.Abs(off.Evaluate(82.0).Gamma - 0.025696), 0.0, 1e-5); + } + + [Fact] + public void LogSpot_on_ATM_price_matches_oracle() + { + AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); + var pricer = new DynamicChebyshevAmericanOptionPricer(); + + double linPrice = pricer.Build(request, BaseSettings() with { LogSpot = false }).Evaluate(100.0).Price; + double logPrice = pricer.Build(request, BaseSettings() with { LogSpot = true }).Evaluate(100.0).Price; + + Assert.True(double.IsFinite(logPrice)); + Assert.InRange(Math.Abs(logPrice - 6.088238), 0.0, 0.15); // QLNet FD oracle band + Assert.InRange(Math.Abs(logPrice - linPrice), 0.0, 0.05); // coordinate change preserves value + } + + [Fact] + public void LogSpot_on_chainrule_delta_gamma_match_linear_baseline() + { + AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); + var pricer = new DynamicChebyshevAmericanOptionPricer(); + + DynamicChebyshevEvaluation lin = + pricer.Build(request, BaseSettings() with { LogSpot = false }).Evaluate(100.0); + DynamicChebyshevEvaluation log = + pricer.Build(request, BaseSettings() with { LogSpot = true }).Evaluate(100.0); + + // Chain-rule Greeks (Delta = u'/S, Gamma = (u'' - u')/S^2) must track the trusted linear baseline. + Assert.InRange(Math.Abs(log.Delta - lin.Delta), 0.0, 0.01); + Assert.InRange(Math.Abs(log.Gamma - lin.Gamma), 0.0, 0.005); + } + + [Fact] + public void LogSpot_on_is_finite_where_linear_fails_at_high_node_counts() + { + AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); + var pricer = new DynamicChebyshevAmericanOptionPricer(); + + foreach (int n in new[] { 161, 321 }) + { + // Document the linear failure: at high n the wide [5,250] build is non-finite or throws. + bool linearBad; + try + { + double linGamma = pricer.Build(request, BaseSettings(n) with { LogSpot = false }).Evaluate(82.0).Gamma; + linearBad = !double.IsFinite(linGamma); + } + catch (ArgumentException) + { + linearBad = true; + } + + _output.WriteLine($"n={n}: linear bad (threw or non-finite) = {linearBad}"); + + // The log-spot build must complete and return finite values at the same n. + DynamicChebyshevAmericanOptionModel log = pricer.Build(request, BaseSettings(n) with { LogSpot = true }); + DynamicChebyshevEvaluation atm = log.Evaluate(100.0); + DynamicChebyshevEvaluation near = log.Evaluate(82.0); + Assert.True( + double.IsFinite(atm.Price) && double.IsFinite(near.Delta) && double.IsFinite(near.Gamma), + $"log-spot must be finite at n={n}"); + Assert.InRange(Math.Abs(atm.Price - 6.088238), 0.0, 0.15); + } + } + + [Fact] + public void LogSpot_spot82_gamma_convergence_measurement() + { + AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); + var pricer = new DynamicChebyshevAmericanOptionPricer(); + const double oracleGamma82 = 0.033689; + + _output.WriteLine("n linGamma82 linErr logGamma82 logErr linStatus"); + double logErrAt81 = double.NaN; + foreach (int n in new[] { 81, 161, 321 }) + { + string linStatus; + double linGamma = double.NaN; + double linErr = double.NaN; + try + { + linGamma = pricer.Build(request, BaseSettings(n) with { LogSpot = false }).Evaluate(82.0).Gamma; + linErr = Math.Abs(linGamma - oracleGamma82); + linStatus = double.IsFinite(linGamma) ? "ok" : "non-finite"; + } + catch (ArgumentException) + { + linStatus = "THREW"; + } + + DynamicChebyshevAmericanOptionModel logModel = + pricer.Build(request, BaseSettings(n) with { LogSpot = true }); + double logGamma = logModel.Evaluate(82.0).Gamma; + double logErr = Math.Abs(logGamma - oracleGamma82); + double logPrice = logModel.Evaluate(100.0).Price; + if (n == 81) + { + logErrAt81 = logErr; + } + + _output.WriteLine( + $"{n,-5} {linGamma,10:F6} {linErr,9:F6} {logGamma,10:F6} {logErr,9:F6} {linStatus}"); + + // Sanity only; whether log-spot Gamma converges toward the oracle is the measured, reported + // question (the F1 decision gate), not a pass/fail assertion. + Assert.True(double.IsFinite(logGamma), $"log Gamma finite at n={n}"); + Assert.InRange(Math.Abs(logPrice - 6.088238), 0.0, 0.15); + } + + _output.WriteLine("oracle Gamma(82) = 0.033689; linear@81 anchor = 0.025696 (err 0.007993)"); + Assert.InRange(logErrAt81, 0.0, 0.015); + } +} From ca0cf72f65ac7d2663c3fb6ca8d04bc919163b44 Mon Sep 17 00:00:00 2001 From: Max Zhang Date: Sun, 7 Jun 2026 14:18:31 -0400 Subject: [PATCH 2/2] refactor(american-option): retire the BoundarySplit experiment, keep the boundary finder Now that the log-spot variant is the real boundary-aware path, retire the naive BoundarySplit flag. It splits a smooth continuation at a non-singularity and only relocates the Gamma query onto the ill-conditioned Chebyshev piece edge (non-physical negative Gamma). The documented finding stays in the case study; the reusable FindExerciseBoundary (Math.NET Brent) is kept. Removed: the BoundarySplit settings flag, the split branch in Build, BuildContinuationSpline, EvaluateSpline, the now-unused firstContinuationFunction/firstFunc plumbing, and the two split-specific tests. Kept: FindExerciseBoundary + its test (renamed AmericanOptionBoundaryFinderTests). Docs: added the concrete global-vs-split Gamma-ringing table to the negative-result note (preserving the evidence now that the runnable flag is gone), and fixed the high-n failure description (ill-conditioning of the wide grid, not quadrature overflow). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/american-option-dynamic-chebyshev.md | 18 ++- .../DynamicChebyshev.cs | 58 --------- .../AmericanOptionBoundaryFinderTests.cs | 41 +++++++ .../AmericanOptionBoundarySplitTests.cs | 113 ------------------ 4 files changed, 57 insertions(+), 173 deletions(-) create mode 100644 tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundaryFinderTests.cs delete mode 100644 tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundarySplitTests.cs diff --git a/docs/docs/american-option-dynamic-chebyshev.md b/docs/docs/american-option-dynamic-chebyshev.md index 8032f7e..573c171 100644 --- a/docs/docs/american-option-dynamic-chebyshev.md +++ b/docs/docs/american-option-dynamic-chebyshev.md @@ -804,8 +804,22 @@ Gamma query onto the **clustered edge of the upper piece**, where a Chebyshev second-derivative differentiation matrix is most ill-conditioned (a measured ~4000x edge amplification versus the interior). The split even produced a non-physical *negative* Gamma one tick above the knot. Raising the node count -makes both variants worse, because the wide-spot Gauss-Hermite quadrature loses -finite values at high `n`. +does not rescue it — the wide `[5, 250]` grid becomes ill-conditioned at high +`n` and the build returns non-finite values (`n = 321` throws). + +The wiggle is concrete (`B0 = 81.868`): the split-piece Gamma rings across the +knot edge while the global interpolant climbs smoothly. + +| spot | global Γ | split Γ | +| ---: | ---: | ---: | +| 81.85 | 0.000000 | 0.000000 | +| 81.90 | 0.025163 | **-0.000404** | +| 82.00 | 0.025696 | 0.019329 | +| 82.25 | 0.026978 | 0.011920 | +| 82.60 | 0.028640 | 0.036449 | + +Negative one tick above the knot, a trough at `82.25`, then an overshoot past the +global by `82.60` — endpoint ringing, not signal. What *does* help, cheaply, is seeding the terminal backward step with the exact one-period European Black-Scholes value (the `ClosedFormTerminalStep` option) diff --git a/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs b/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs index 230e8b2..e180906 100644 --- a/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs +++ b/examples/AmericanOptionDynamicChebyshev/DynamicChebyshev.cs @@ -10,14 +10,6 @@ public sealed record DynamicChebyshevSettings( double SpotUpper = 250.0, int QuadratureOrder = 8, bool ClosedFormTerminalStep = false, - // Experimental, and a DOCUMENTED NEGATIVE RESULT: splitting the smooth continuation at the - // exercise boundary does not improve Greeks. The kink lives in the price max(payoff, C), which - // is applied exactly outside the interpolant; the continuation C is smooth at the boundary, so a - // knot there resolves no singularity and only relocates the Gamma query onto the ill-conditioned - // Chebyshev piece edge (it yields a non-physical negative Gamma one tick above the knot). Kept, - // default-off, for reproducibility; superseded by the planned front-fixing variant. See the - // "Why the boundary Gamma is weak" section of the American-option case study. - bool BoundarySplit = false, // Stage F1 front-fixing: interpolate the continuation in x = log(S) instead of linear S. The GBM // transition is additive in x, so the Gauss-Hermite images stay bounded; the narrow uniform-in-x // grid is also far better conditioned at high node counts, curing the high-n non-finite build @@ -167,7 +159,6 @@ public DynamicChebyshevAmericanOptionModel Build( ? x => Payoff(request, Math.Exp(x)) : spot => Payoff(request, spot); ChebyshevApproximation? firstContinuation = null; - Func? firstContinuationFunction = null; for (int step = settings.ExerciseSteps - 1; step >= 0; step--) { @@ -214,16 +205,13 @@ public DynamicChebyshevAmericanOptionModel Build( if (step == 0) { firstContinuation = continuation; - firstContinuationFunction = continuationFunction; } } stopwatch.Stop(); Debug.Assert(firstContinuation is not null); - Debug.Assert(firstContinuationFunction is not null); ChebyshevApproximation firstApprox = firstContinuation!; - Func firstFunc = firstContinuationFunction!; Func continuationCurve; if (settings.LogSpot) @@ -250,17 +238,6 @@ public DynamicChebyshevAmericanOptionModel Build( return (u2 - u1) / (spot * spot); }; } - else if (settings.BoundarySplit && request.Right == VanillaOptionRight.Put) - { - double GlobalContinuation(double spot) => - EvaluateApproximation(firstApprox, spot, settings, derivativeOrder: 0); - double PayoffAt(double spot) => Payoff(request, spot); - double boundary = FindExerciseBoundary(GlobalContinuation, PayoffAt, settings.SpotLower, request.Strike); - - ChebyshevSpline spline = BuildContinuationSpline(settings, firstFunc, boundary); - buildEvaluations += spline.NumPieces * settings.SpotNodeCount; - continuationCurve = (spot, order) => EvaluateSpline(spline, spot, settings, order, boundary); - } else { continuationCurve = (spot, order) => EvaluateApproximation(firstApprox, spot, settings, order); @@ -439,41 +416,6 @@ internal static double FindExerciseBoundary( return MathNet.Numerics.RootFinding.Brent.FindRoot(Gap, lo, hi, accuracy: 1e-8, maxIterations: 100); } - private static ChebyshevSpline BuildContinuationSpline( - DynamicChebyshevSettings settings, Func continuation, double knot) - { - double Function(double[] point, object? _) => continuation(point[0]); - - var spline = new ChebyshevSpline( - Function, - numDimensions: 1, - domain: [[settings.SpotLower, settings.SpotUpper]], - nNodes: [settings.SpotNodeCount], - knots: [[knot]], - maxDerivativeOrder: 2); - spline.Build(verbose: false); - return spline; - } - - private static double EvaluateSpline( - ChebyshevSpline spline, - double spot, - DynamicChebyshevSettings settings, - int derivativeOrder, - double knot) - { - double clamped = Math.Clamp(spot, settings.SpotLower, settings.SpotUpper); - - // Derivatives are undefined exactly at the knot (adjacent pieces disagree there); nudge onto - // the continuation side so Greeks remain queryable right at the boundary. - if (derivativeOrder > 0 && Math.Abs(clamped - knot) < 1e-9) - { - clamped = knot + 1e-7; - } - - return spline.Eval([clamped], [derivativeOrder]); - } - private static void Validate( AmericanOptionRequest request, DynamicChebyshevSettings settings) diff --git a/tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundaryFinderTests.cs b/tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundaryFinderTests.cs new file mode 100644 index 0000000..6bc5d5b --- /dev/null +++ b/tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundaryFinderTests.cs @@ -0,0 +1,41 @@ +using System.Reflection; +using AmericanOptionDynamicChebyshev; + +namespace ChebyshevSharp.Tests.Finance; + +/// +/// The exercise-boundary finder: B (where payoff = continuation) located by Math.NET Brent +/// root-finding. The boundary itself is a useful diagnostic and the basis for any future +/// boundary-aware variant. (The earlier boundary-split experiment was retired as a documented +/// negative result — see the case study — once the log-spot variant superseded it.) +/// +public sealed class AmericanOptionBoundaryFinderTests +{ + [Fact] + public void Exercise_boundary_is_located_near_the_diagnostic_value() + { + AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); + var settings = new DynamicChebyshevSettings(80, 81, 5.0, 250.0, 8); + DynamicChebyshevAmericanOptionModel model = + new DynamicChebyshevAmericanOptionPricer().Build(request, settings); + + double boundary = InvokeFindExerciseBoundary(model, lo: 5.0, hi: request.Strike); + + // The diagnostics mode reports the first-step exercise boundary around spot 81.86. + Assert.InRange(boundary, 80.0, 84.0); + } + + private static double InvokeFindExerciseBoundary( + DynamicChebyshevAmericanOptionModel model, double lo, double hi) + { + MethodInfo method = typeof(DynamicChebyshevAmericanOptionPricer) + .GetMethod( + "FindExerciseBoundary", + BindingFlags.Static | BindingFlags.NonPublic, + binder: null, + types: [typeof(DynamicChebyshevAmericanOptionModel), typeof(double), typeof(double)], + modifiers: null) + ?? throw new InvalidOperationException("FindExerciseBoundary is not implemented."); + return (double)method.Invoke(null, [model, lo, hi])!; + } +} diff --git a/tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundarySplitTests.cs b/tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundarySplitTests.cs deleted file mode 100644 index 2be4e43..0000000 --- a/tests/ChebyshevSharp.Tests/Finance/AmericanOptionBoundarySplitTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Reflection; -using AmericanOptionDynamicChebyshev; -using Xunit.Abstractions; - -namespace ChebyshevSharp.Tests.Finance; - -/// -/// W3 — boundary-aware split. The exercise boundary B_i (where payoff = continuation) is a -/// moving kink; locating it by root-finding lets the continuation be represented as a two-piece -/// ChebyshevSpline with a knot at B_i, restoring per-piece smoothness near the boundary. -/// -public sealed class AmericanOptionBoundarySplitTests -{ - private static readonly IAmericanOptionReferencePricer ReferencePricer = - new QlNetAmericanOptionReferencePricer(); - - private readonly ITestOutputHelper _output; - - public AmericanOptionBoundarySplitTests(ITestOutputHelper output) => _output = output; - - [Fact] - public void Boundary_split_reduces_gamma_error_near_the_exercise_boundary() - { - AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); - var baseSettings = new DynamicChebyshevSettings(80, 81, 5.0, 250.0, 8); - var pricer = new DynamicChebyshevAmericanOptionPricer(); - DynamicChebyshevAmericanOptionModel global = pricer.Build(request, baseSettings); - DynamicChebyshevAmericanOptionModel split = - pricer.Build(request, baseSettings with { BoundarySplit = true }); - - double[] spots = [82.0, 86.0, 90.0, 94.0, 98.0, 102.0, 106.0, 110.0, 114.0, 118.0]; - _output.WriteLine("spot oracleGamma globalGamma globalErr splitGamma splitErr"); - double globalErrAt82 = 0.0; - double splitErrAt82 = 0.0; - foreach (double s in spots) - { - double oracle = ReferencePricer.Price(request with { Spot = s }).Gamma; - double globalGamma = global.Evaluate(s).Gamma; - double splitGamma = split.Evaluate(s).Gamma; - double globalErr = Math.Abs(globalGamma - oracle); - double splitErr = Math.Abs(splitGamma - oracle); - _output.WriteLine( - $"{s,5:F1} {oracle,11:F6} {globalGamma,11:F6} {globalErr,9:F6} {splitGamma,11:F6} {splitErr,9:F6}"); - if (s == 82.0) - { - globalErrAt82 = globalErr; - splitErrAt82 = splitErr; - } - } - - _output.WriteLine( - $"Boundary-row (spot 82) Gamma abs error: global={globalErrAt82:F6}, split={splitErrAt82:F6}"); - - // Sanity/regression guard only. Whether the split REDUCES the boundary-row Gamma error is - // the empirical question reported in the table above, and the answer is mixed: it improves - // rows a little further out (e.g. spot 86) but worsens the row immediately adjacent to the - // knot (spot 82, only ~0.14 above B0), where the per-piece second derivative at the piece - // edge is unreliable. This test therefore only asserts the split keeps pricing accurate and - // the Gamma profile finite. - Assert.InRange(Math.Abs(split.Evaluate(request.Spot).Price - 6.088238), 0.0, 0.15); - Assert.True(double.IsFinite(globalErrAt82) && double.IsFinite(splitErrAt82)); - } - - [Fact] - public void Exercise_boundary_is_located_near_the_diagnostic_value() - { - AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); - var settings = new DynamicChebyshevSettings(80, 81, 5.0, 250.0, 8); - DynamicChebyshevAmericanOptionModel model = - new DynamicChebyshevAmericanOptionPricer().Build(request, settings); - - double boundary = InvokeFindExerciseBoundary(model, lo: 5.0, hi: request.Strike); - - // The diagnostics mode reports the first-step exercise boundary around spot 81.86. - Assert.InRange(boundary, 80.0, 84.0); - } - - [Fact] - public void Boundary_split_stays_near_the_oracle_and_changes_greeks_near_the_boundary() - { - AmericanOptionRequest request = AmericanOptionScenarios.StandardPut(); - var baseSettings = new DynamicChebyshevSettings(80, 81, 5.0, 250.0, 8); - var pricer = new DynamicChebyshevAmericanOptionPricer(); - - DynamicChebyshevAmericanOptionModel global = pricer.Build(request, baseSettings); - DynamicChebyshevAmericanOptionModel split = - pricer.Build(request, baseSettings with { BoundarySplit = true }); - - // Price stays accurate against the QLNet FD oracle (6.088238). - Assert.InRange(Math.Abs(split.Evaluate(request.Spot).Price - 6.088238), 0.0, 0.15); - - // Splitting at the boundary changes the second derivative near it (spot 82, next to ~81.86), - double globalGamma = global.Evaluate(82.0).Gamma; - double splitGamma = split.Evaluate(82.0).Gamma; - Assert.NotEqual(globalGamma, splitGamma); - // and Gamma stays a sane positive convexity. - Assert.True(splitGamma > 0.0); - } - - private static double InvokeFindExerciseBoundary( - DynamicChebyshevAmericanOptionModel model, double lo, double hi) - { - MethodInfo method = typeof(DynamicChebyshevAmericanOptionPricer) - .GetMethod( - "FindExerciseBoundary", - BindingFlags.Static | BindingFlags.NonPublic, - binder: null, - types: [typeof(DynamicChebyshevAmericanOptionModel), typeof(double), typeof(double)], - modifiers: null) - ?? throw new InvalidOperationException("FindExerciseBoundary is not implemented yet."); - return (double)method.Invoke(null, [model, lo, hi])!; - } -}