Both answers already address the gist of the question. I decided to add (quite) some details because I think there is some confusion from the OP. It is not the future that has carry costs or benefits but the underlying. This is also an important fact for pricing options. However, there is no additional "feature" to the cost of carry that is not already incorporated in the forward pricing if you replicate a forward synthetically. Any deviation from the fair future (zero cost as no one is made better or worse off) will result in a compensation of one counterparty. That's it. The rest below just elaborates on this.
The following screenshot displays for various different implied volatilities (IV), the associated call (c) and put (p) values computed with Black Scholes (with cost of carry), Black76 (without cost of carry but using the forward / future as the underlying), the fair forward / future (Fwd), the strike used, the price of the synthetic long future/forward (C-P), and the compensation for not using the fair forward /future $((F-K)*e^{-r*t})$, as well as the Put & Call values computed with put call parity (PCP). As you can see, forwards and synthetic forwards always match each other in terms of pricing and the cost of carry is always the same. The code and explanations will follow below.
As you can see, all that matters is where K is relative to the fair future. The option prices always adjusts for this and simply resemble the price of the forward /future (as should be, otherwise there would be arbitrage).
Details
The Black Scholes Merton (BSM) and Black 76 option pricing models are both well-known and widely used. The only model difference versus the BSM model is that the underlying future in the Black model has no carry costs or benefits.
In words, the cost of carry relationship describes the relative cost of buying a stock with deferred delivery (the future) versus buying it in the spot market with immediate delivery and "carrying" it forward. If you buy stock now, you tie up your funds and incur a time value of money cost of $r$ per period. On the other hand, you receive dividend payments (carry benefit) of $d$. (Technically, being short spot inverses this and the cost of carry is the cost of paying dividends).
This advantage must be offset by a differential between the futures and the spot price. Therefore, OPs comment made in @Jan Stuller's answer makes no sense:
If it were immaterial everyone would buy ATM calls, sell ATM puts, and
sell futures. Net price movement of such a strategy is zero, yet
selling futures earns one (interest rate - dividend yield) every year.
Which would be free money that I could leverage as high as my broker
would let me.
The future price is exactly offsetting this difference and there is no free money. It may be less obvious with equity but should be quite clear with FX (where the concept is identical, just with two interest rates). It is called Covered Interest Parity (CIP).
No matter what you do, returns from investing domestically are equal to the returns from investing abroad. This works because you enter a forward and fix that rate that guarantees no arbitrage.
Now, back to options, let’s begin with BSM which has the carry costs (and benefits) of the underlying incorporated. After all, the model is for European options (hence deferred delivery) just like equity futures, but it has the spot market as the underlying (like equity futures). Writing BSM in Julia looks like this (I like to use it because the code looks almost like a math textbook and the language offers simple, yet powerful plotting libraries paired with speed):
using Distributions
N(x) = cdf(Normal(0,1),x)
function BSM(s,k,t,r,d, σ, cp)
d1 = ( log(s/k) + (r -d+ σ^2/2)*t ) / (σ*sqrt(t))
d2 = d1 - σ*sqrt(t)
opt = exp(-d*t)*cp*s*N(cp*d1) - cp*k*exp(-r*t)*N(cp*d2)
delta = cp*exp(-d*t)*N(cp*d1)
return opt, delta
end
CP is a call put flag (1 for call, -1 for put). Ignoring it, one has the following formula:
$$e^{-d*t}*S*N(d1) - K*e^{-r*t}*N(d2)$$
where $d$ is the dividend, $r$ is the risk-free rate and $d1$, $d2$ are the standard BSM model inputs as shown on Wikipedia. To highlight the carry benefit adjustment in the BSM model, one can rewrite the call and put value as the present value (PV) of the expected option payoff at expiration.
$$E(c_T) = \color{blue}{S*e^{(r-d)*T}}*N(d1) - K*N(d2)$$
and
$$E(p_T) = K*N(-d2) - \color{blue}{S*e^{(r-d)*T}}*N(-d1)$$
It should be clear now why the cp flag in the BSM function in the Julia code works.
This also shows nicely that the BSM model value is really just a dynamically managed portfolio of the stock and zero-coupon bonds (the financing part, which can also be seen as bank borrowing or lending). The discounted price of the zero coupon bond is $K*e^{-r*T}$, the stock itself is influenced by the carry benefits (cb) and high cb will lower the call option price. In summary, for calls, one needs to by N(d1) stocks (adjusted for the carry benefit) and N(d2) bonds. Since N(d2) < 0 and N(d1) > 0 one needs to borrow to buy the stock.
Now, let's plug in some hypothetical numbers. I'll use t = 1 year throughout to avoid complications with daycount differences between rates, dividends, IV and so forth.
using DataFrames
s,k,t,d,r,σ = 100, 100, 1,0.03, 0.04, 0.3
call = BSM(s,k,t,r,d,σ,1)
put = BSM(s,k,t,r,d,σ,-1)
df = DataFrame("Call" => call[1],"Delta Call" => call[2], "Put" => put[1], "Delta Put" => put[2])
PrettyTables.pretty_table(df, border_crayon = Crayons.crayon"blue", header_crayon = Crayons.crayon"bold green", formatters = ft_printf("%.4f", [1,2,3,4]))
This is ATM Spot, hence the resulting synthetic forward would not be zero cost. Nonetheless, the (carry benefit adjusted) put-call parity, defined as
$$p + S*e^{-d*t} == c + e^{-r*t}*K$$
works, as it does for any strike.
println("PC Parity computed Put value = $(round((c + exp(-r*t)*k -s*exp(-d*t)),digits = 4))")
println("Put Price according to BSM = $(round(put[1],digits = 4))")
PC Parity computed Put value = 11.0371
Put Price according to BSM = 11.0371
If we want to price a synthetic zero cost forward we need to first compute the fair future value of the stock, $S*e^{(r-d)*T}$. We can put this argument one step further. I claimed that Black 76 has no carry costs or benefits to take care of, because the future is already computed with the carrying cost of the spot "in mind". Let's define Black in Julia:
function Black76(F,K,t,r,σ, cp)
d1 = (log(F/K) + 0.5*σ^2*t)/ σ*sqrt(t)
d2 = d1 - σ*sqrt(t)
opt = cp*exp(-r*t)*(F*N(cp*d1) - K*N(cp*d2))
return opt
end
Rates or dividends show up nowhere, apart from discounting the expected payoff back to today. Interesting side remark: futures contracts are marked to market and so the payoff is realized when the option is exercised. If we would consider an option on a forward contract expiring at time T̃ > T, the payoff doesn't occur until T̃. Thus, the discount factor would need to take this extra time into account.
Combining this into a DF shows that this indeed yields the desired output:
k = s*exp((r-d)*t)
f = k
call = BSM(s,k,t,r,d,σ,1)
put = BSM(s,k,t,r,d,σ,-1)
call_Black = Black76(f,k,t,r,σ,1)
put_Black = Black76(f,k,t,r,σ,-1)
df = DataFrame("Call" => call[1],"Delta Call" => call[2], "Put" => put[1], "Delta Put" => put[2], "Forward" => k, "Call Black76" => call_Black, "Put Black76" => put_Black)
PrettyTables.pretty_table(df, border_crayon = Crayons.crayon"blue", header_crayon = Crayons.crayon"bold green", formatters = ft_printf("%.4f", [1,2,3,4]))
Now, we do have a zero-cost synthetic forward. Cost of carry play no further role here, besides defining the fair future value (or strike). $\color{blue}{Deviating\ from\ this\ fair\ price\ will\ just\ mean\ that\ you\ pay\ or\ receive\\ an\ upfront\ compensation\ and\ it\ is\ not\ zero\ cost\ at\ initiation.}$
However, with equity options you have another problem. Many stock options are American. As such, your position may be subject to early exercise. here are 2 circumstances that can lead to the value of an European option being lower than intrinsic value
- a) deep ITM puts in presence of positive interest rates r>0
- b ) deep ITM calls in presence of positive dividend yield q>0
which also coincides with the 2 circumstances under which it makes sense for an American option to be exercised early (which can matter for synthetic equity forwards). Some intuition is given here, where the graphic below is taken from.
Any area that is shaded (to the right) will mean early exercise for American options. Insofar, you do not really own a synthetic forward, because if spot moves significantly, one of your legs will be terminated early. On top of that, these options are frequently a lot less liquid compared to the future and as Jan Stuller wrote, you will have two transactions instead of one.
Edit
I highlighted the formula in blue to show that cost of carry is fully integrated into BSM. I mentioned that Black (for pricing option on futures) does not need this cost of carry adjustment because there is no cost or carry for futures. I wrote that cost of carry plays no further role besides determining the fair future (or strike). I added all code and numerical examples. Now, all that is left is to try out a few different values to see that there is indeed no such thing as moneyness or delta adjusted carry. The cost of carry is simply a no arbitrage formula that means no one is made better or worse - hence there is no upfront cost.
Let's look at a numerical example where we do not use the fair strike. As shown above, for ATM spot, the value for a call and put are not identical and the options cost 12.0027 and 11.0371 respectively, hence a total cost of ~0.9656. A shown, the fair forward is $f = s*exp((r-d)*t) \approx 101.005$. Discounting the difference (there is always time value for everything) gives you $(f-k)*e^{-r*t} \approx 0.9656$. That it matches the difference between the call and put is no coincidence, but a simple correction for the unfair (synthetic) forward you enter. This works with any value of interest rates, dividends, IV and whatever else concerns option pricing (as long as you we are talking about European options). Below is the (somewhat messy) code, that creates the DataFrame from the very top. The highligthed lines indicate where K changes (and IV starts to iterate from the beginning).
# define range of volatilities
σ = 0.1:0.1:1
#compute fair forward
f = s*exp((r-d)*t)
# try a few different strikes (ATM, OTM, ITM)
k,k2,k0 = 102,98,f
# define Black Scholes for the strikes
call, call2, call0 = BSM.(s,k,t,r,d,σ,1),BSM.(s,k2,t,r,d,σ,1), BSM.(s,k0,t,r,d,σ,1)
put, put2, put0 = BSM.(s,k,t,r,d,σ,-1), BSM.(s,k2,t,r,d,σ,-1), BSM.(s,k0,t,r,d,σ,-1)
call_Black,call_Black2, call_Black0 = Black76.(f,k,t,r,σ,1), Black76.(f,k2,t,r,σ,1), Black76.(f,k0,t,r,σ,-1)
put_Black, put_Black2, put_Black0 = Black76.(f,k,t,r,σ,-1), Black76.(f,k2,t,r,σ,-1), Black76.(f,k0,t,r,σ,-1)
# create result arrays
vols = append!(append!([σ[i] for i in 1:1:length(σ)],[σ[i] for i in 1:1:length(σ)], [σ[i] for i in 1:1:length(σ)]))
c = append!(append!([call0[i][1] for i in 1:1:length(σ)], [call[i][1] for i in 1:1:length(σ)], [call2[i][1] for i in 1:1:length(σ)]))
p = append!(append!([put0[i][1] for i in 1:1:length(σ)], [put[i][1] for i in 1:1:length(σ)],[put2[i][1] for i in 1:1:length(σ)]))
c_black = append!(append!([call_Black0[i][1] for i in 1:1:length(σ)], [call_Black[i][1] for i in 1:1:length(σ)],[call_Black2[i][1] for i in 1:1:length(σ)]))
p_black = append!(append!([put_Black0[i][1] for i in 1:1:length(σ)], [put_Black[i][1] for i in 1:1:length(σ)],[put_Black2[i][1] for i in 1:1:length(σ)]))
# create dataframe
df = DataFrame("IV" => vols ,
"C" => c, "P" => p,
"C Black" => c_black, "P Black" => p_black,
"Fwd" => f,
"Strike" => append!(append!([k0 for i in 1:1:length(σ)], [k for i in 1:1:length(σ)],[k2 for i in 1:1:length(σ)])),
"C - P" => [round(c[i][1].-p[i][1],digits =3) for i in 1:1:length(vols)],
"(F-K)*e^(-r*t)" => append!(append!([round((f-k0)*exp(-r*t),digits = 3) for i in 1:1:length(σ)], [round((f-k)*exp(-r*t),digits = 3) for i in 1:1:length(σ)],[round((f-k2)*exp(-r*t),digits =3) for i in 1:1:length(σ)]))
)
## put call parity computations for call and put
df[!, "PCP C"] = round.(df.P .- exp(-r*t).*df.Strike .+ s*exp(-d*t), digits = 4)
df[!, "PCP P"] = round.(df.C .+ exp(-r*t).*df.Strike .- s*exp(-d*t), digits = 4)
# PrettyTables formatting
hl_1 = Highlighter((data,i,j) -> data[i,1] == 0.100, crayon"bg:dark_gray white bold")
h2 = Highlighter( (data,i,j)->j in (8, 9) && data[i, j] == 2.887,
bold = true,
foreground = :blue )
h3 = Highlighter( (data,i,j)->j in (8, 9) && data[i, j] == 0.000,
bold = true,
foreground = :green )
PrettyTables.pretty_table(df, border_crayon = Crayons.crayon"blue", header_crayon = Crayons.crayon"bold green", formatters = ft_printf("%.3f", [1,2,3,4,5,6,7,8,9,10,11]), highlighters = (hl_1, hl_value(-0.956), h2,h3))
Julia Ad-on
Unrelated to the question, but the animation is pure Julia code. The sliders (and much more) can be created with Interact. A nice demo (in my opinion, which may be biased because I wrote it) is the very short code below, which plots interactive 3D surfaces of the call value and various greeks in spot and time dimension. As long as Black Scholes is defined (as above just with more Greeks), the actual chart is just 7 lines of code. The quality is reduced here because the allowed GIF size is very small in imgur.
gui = @manipulate for K=K_range, rf=rf_range,d=d_range,σ = 0.01:0.1:1.11,α=0.1:0.1:1, side = 10:1:45,up = 20:2:52;
z = [Surface((spot,time)->BSM.(spot,K,time,rf,d,σ)[i], spot, time) for i in 1:1:6]
title = ["Call Value", "Vega","Delta","Gamma","Theta","Rho"]
p = [surface(spot,time,z[i], camera=(12,20),α=0.8 ,xlabel="Spot",ylabel="time",title=title[i],legend = :none) for i in 1:1:6]
plot(p[1],p[2],p[3],p[4],p[5],p[6],layout=(3,3), size =(1000,800))
end
@layout! gui vbox(vbox(hbox(K,rf,d,σ),hbox(α,side,up)), observe(_))
In case anyone is interested in Julia, I did some work a while ago which I partially shared on Econ Stack to showcase why Julia is not slowing you down (unless your code is written in non-performant ways).