FX Option Valuation Dates: Expiry, Premium, and Delivery
Summary
The document investigates why QuantLib’s daily valuations of a short-dated FX option diverge from Bloomberg on some dates, despite matching on several others. The accepted answer identifies differing date conventions: Bloomberg distinguishes the pricing date, premium date, option expiry, and delivery date, while the described QuantLib setup does not separately model the pricing and premium dates. Time to expiry affects option uncertainty, while time to delivery affects discounting and the forward rate.
The answer illustrates a forward-based Garman–Kohlhagen valuation that uses separate expiry and delivery intervals, and notes that the sample implementation must use a put to match the trade being compared. It concludes that the cited QuantLib setup cannot directly reproduce settlement adjustments or delayed delivery, and points to another library example that matches the displayed intermediate values. The comparison is tied to a specific FX option and market setup; the shown figures have rounding limits, and the thread does not establish behavior for every QuantLib configuration or product.
Key ideas
- FX option valuation can depend separately on time to expiry and time to delivery.
- The described QuantLib setup does not distinguish Bloomberg’s pricing date from its premium date.
- Forward-rate construction and discounting need dates consistent with the option’s settlement conventions.
- The example’s discrepancy is attributed to settlement and delayed-delivery handling in the compared setups.
- The numerical comparison uses a particular option setup and includes rounding limitations.
Tags
Full text
# Quantlib: day-by-day evaluation of option value
# Quantlib: day-by-day evaluation of option value
I'm using Quantlib in Python to price an FX option. I'm comparing the result to Bloomberg, to make sure the code is working correct.
I want to calculate the P&L of a certain option trading strategy by using Taylor expansion of P&L (discussed in other post here) And also by using the NPV of the option.
Therefore, it's important to have a correct NPV for every date that the option is alive. The option I use to test this is a 1-week stylized option.
The problem that occurs is that the NPV matches Bloomberg correctly on the first, second, third and last date, but not on the other dates.
```
import QuantLib as ql
Spot = 1.1
Strike = 1.101
Sigma = 10/100
Ccy1Rate = 5/100
Ccy2Rate = 10/100
OptionType = ql.Option.Call
#Option dates in quantlib objects
EvaluationDate = ql.Date(3, 1,2022)
SettlementDate = ql.Date(5, 1, 2022) #Evaluation +2
ExpiryDate = ql.Date(10, 1, 2022) #Evaluation + term which is 1 week
DeliveryDate = ql.Date(12, 1, 2022) #Expiry +2
NumberOfDaysBetween = ExpiryDate - EvaluationDate
#print(NumberOfDaysBetween)
#Generate continuous interest rates
EurRate = Ccy1Rate
UsdRate = Ccy2Rate
#Create QuoteHandle objects. Easily to adapt later on.
#You can only access SimpleQuote objects. When you use setvalue, you can change it.
#These global variables will then be used in pricing the option.
#Everything will be adaptable except for the strike.
SpotGlobal = ql.SimpleQuote(Spot)
SpotHandle = ql.QuoteHandle(SpotGlobal)
VolGlobal = ql.SimpleQuote(Sigma)
VolHandle = ql.QuoteHandle(VolGlobal)
UsdRateGlobal = ql.SimpleQuote(UsdRate)
UsdRateHandle = ql.QuoteHandle(UsdRateGlobal)
EurRateGlobal = ql.SimpleQuote(EurRate)
EurRateHandle = ql.QuoteHandle(EurRateGlobal)
#Settings such as calendar, evaluationdate; daycount
Calendar = ql.UnitedStates()
ql.Settings.instance().evaluationDate = EvaluationDate
DayCountRate = ql.Actual360()
DayCountVolatility = ql.ActualActual()
#Create rate curves, vol surface and GK process
RiskFreeRateEUR = ql.YieldTermStructureHandle(ql.FlatForward(0, Calendar, EurRateHandle, DayCountRate))
RiskFreeRateUSD = ql.YieldTermStructureHandle(ql.FlatForward(0, Calendar, UsdRate, DayCountRate))
Volatility = ql.BlackVolTermStructureHandle(ql.BlackConstantVol(0, Calendar, VolHandle, DayCountVolatility))
GKProcess = ql.GarmanKohlagenProcess(SpotHandle, RiskFreeRateEUR, RiskFreeRateUSD, Volatility)
#Generate option
Payoff = ql.PlainVanillaPayoff(OptionType, Strike)
Exercise = ql.EuropeanExercise(ExpiryDate)
Option = ql.VanillaOption(Payoff, Exercise)
Option.setPricingEngine(ql.AnalyticEuropeanEngine(GKProcess))
BsPrice = Option.NPV()
ql.Settings.instance().includeReferenceDateEvents = True
ql.Settings.instance().evaluationDate = EvaluationDate
print("Premium is:", Option.NPV()*1000000/Spot)
ql.Settings.instance().evaluationDate = EvaluationDate+1
print("Premium is:", Option.NPV()*1000000/Spot)
ql.Settings.instance().evaluationDate = EvaluationDate+2
print("Premium is:", Option.NPV()*1000000/Spot)
ql.Settings.instance().evaluationDate = EvaluationDate+3
print("Premium is:", Option.NPV()*1000000/Spot)
ql.Settings.instance().evaluationDate = EvaluationDate+4
print("Premium is:", Option.NPV()*1000000/Spot)
ql.Settings.instance().evaluationDate = EvaluationDate+7
print("Premium is:", Option.NPV()*1000000/Spot)
```
Which results in:
```
Premium is: 5487.479999102207
Premium is: 5148.552323458257
Premium is: 4774.333578225227
Premium is: 4353.586300232529
Premium is: 3867.4561591587326
Premium is: 909.0909090908089
```
However, according to Bloomberg the premium should be:
```
5487.48 (correct)
5148.55 (correct)
4774.33 (correct)
4499.5
4015.7
909.9 (correct)
```
The result on expiration is given by setting the following (the option expires in-the-money):
```
ql.Settings.instance().includeReferenceDateEvents = True
```
Can someone explain why the NPV suddenly doesn't match for those 2 dates?
Screenshots of option pricing in Bloomberg
## Answer by AKdemy (score 10, accepted)
https://quant.stackexchange.com/a/70296
There are two different time gaps in OVML:
- time to expiry = Expiry Date - Price Date
- time to delivery = Delivery Date - Premium Date
You can see the premium date at the bottom of the OVML screen. Quantlib on the other hand does not distinguish price date and premium date (Matlab for example also doesn't distinguish this).
To illustrate this, I will use Julia because I already had this code. However, the syntax is sufficiently similar to Python so it should be possible to follow the logic. I replicate both Bloomberg's valuation and Quantlib's valuation to illustrate the differences.
Import all packages and define the cdf.
```
using Distributions, Dates
N(x) = cdf(Normal(0,1),x)
```
Define Dates
```
price_dt = Date(2022,1,6)
premium_dt = Date(2022,1,10)
expiry_dt = Date(2022,1,10)
delivery_dt = Date(2022,1,12)
days_to_expiry = (expiry_dt - price_dt)
days_to_delivery = (delivery_dt - premium_dt)
println(days_to_expiry)
println(days_to_delivery)
println("Time to expiry = $(days_to_expiry.value/365)")
println("Time to delivery = $(days_to_delivery.value/365)")
```
Result:
Define inputs
```
spot = 1.1
points = 3.06
fwd_scale = 10000
f = spot + points / fwd_scale
k = 1.101
ccy1 = 0.05 # EUR
ccy2 = 0.1 # USD
vol = 0.1
println(f)
```
Compute continuous interest rates (adjusted for differences in daycount between rates and vols)
```
r1_cont = log(1+ccy1*days_to_expiry/360)/(days_to_expiry/365)
r2_cont = log(1+ccy2*days_to_expiry/360)/(days_to_expiry/365)
```
Define Garman Kohlhagen with forward (technically Black76) => same result as can be seen here. Delivery is needed for discounting the put / call values.
```
function GKF(F,K, days_to_expiry, days_to_delivery ,ccy2,σ)
d1 = ( log(F/K) + 0.5*σ^2*days_to_expiry.value/365 ) / (σ*sqrt(days_to_expiry.value/365))
d2 = d1 - σ*sqrt(days_to_expiry.value/365)
c = exp(-ccy2*days_to_delivery.value/365)*(F*N(d1) - K*N(d2))
p = exp(-ccy2*days_to_delivery.value/365)*(-F*N(-d1) + K*N(-d2))
return c, p
end
```
Compute Option value (Julia has 1 based indexing). The division by spot is needed because standard GK is in terms of CCY2 (USD here). Notional is in CCY1, which means we need not change anything here.
```
Option = GKF(f, k, days_to_expiry, days_to_delivery, r2_cont, vol )
put = Option[2]*1000000/spot
```
Result for the first day that is different, as well as the initial day to compare this.
There are minor rounding differences to BBG because I only used the fwd points visible in the screenshot, which lacks the exact decimal precision.
In case someone tries to replicate, the Quantlib code in the question should be (instead of .Call)
```
OptionType = ql.Option.Put
```
You can see the implementation of the code (in c++) here.
The same cpp file defines N()
as well as how the actual value of the option is calculated
.
The crux here is that quantlib does not distinguish price date and premium date (in Bloomberg terminology). The forward is derived from the exchange rate on evaluation date, as well as the interest rate differential, adjusted for daycount. This can be done like so in Julia:
```
fwd = spot * exp((r2_cont - r1_cont)*days_to_delivery.value/365)
```
Now, following our above implementation, and knowing quantlib does not do this, we can simply set the premium date to always be T+2 (for EUR) and ignore any holidays / weekends (we could also just ignore the difference between price/premium as well as expiry/delivery).
With these dates, our forward is
and the option value as computed in quantlib is:
To cross check, if you were to now use the correct dates, you would get the BBG valuation.
Therefore, in quantlib, you cannot take into account settlement adjustment (e.g. T+2 for EURUSD, or T+1 for USDRUB), or compute delayed delivery (where delivery date is T+2, i.e. 2 days after expiry), as for example described in "Wystup, Uwe. FX options and structured products. John Wiley & Sons, 2015. p.26-29" or "Clark, Iain J. Foreign exchange option pricing: A practitioner's guide. John Wiley & Sons, 2011. p.33".
This is something Bloomberg or Murex offer.
## Answer by Attack68 (score 1)
https://quant.stackexchange.com/a/82131
You can now replicate the Bloomberg results in Python's rateslib
Create a function to setup your market and instruments on each of your evaluation dates
```
from rateslib import * # Python 3.12, rateslib 1.7
def market(eval_date):
# determine spot from eval date
spot = add_tenor(eval_date, "2b", "F", "tgt|fed")
# create rates curves in EUR and USD and an FX market
eur = Curve({eval_date: 1.0, dt(2022, 2, 3): 1.0}, id="eur")
usd = Curve({eval_date: 1.0, dt(2022, 2, 3): 1.0}, id="usd")
fxr = FXRates({"eurusd": 1.10}, settlement=spot)
fxf = FXForwards(fx_rates=fxr, fx_curves={"eureur": eur, "eurusd": eur, "usdusd": usd})
# solve the rates curves to have rates of 5% and 10% to delivery
solver = Solver(
curves=[eur, usd],
instruments=[
Value(dt(2022, 1, 12), curves="usd", metric="cc_zero_rate"),
Value(dt(2022, 1, 12), curves="eur", metric="cc_zero_rate"),
],
s=[10.0, 5.0],
fx=fxf,
)
# Create the FX option to value
fxo = FXPut(
expiry=dt(2022, 1, 10),
pair="eurusd",
strike=1.101,
calendar="tgt|fed",
notional=1e6,
premium_ccy="eur",
payment_lag=spot,
delta_type="spot_pa",
delivery_lag=2,
curves=[None, "eur", None, "usd"]
)
return solver, fxo
```
Now we iterate through each of the evaluation dates and print the result:
```
for eval_date in [
dt(2022, 1, 3),
dt(2022, 1, 4),
dt(2022, 1, 5),
dt(2022, 1, 6),
dt(2022, 1, 7),
dt(2022, 1, 10)
]:
solver, fxo = market(eval_date)
print(fxo.rate(solver=solver, vol=10.0) * 10000)
# 5487.48
# 5148.55
# 4774.33
# 4499.50
# 4015.70
# 909.09
```Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.