You only got one minor bug, but let me explain why the range increases.
Let us denote $n:=timesteps$, then
- You are looping one iteration too little when filling your $S$ matrix array, causing you to have S(T-dt) and not S(T) as terminal values. This is because you are not accounting for your starting position, i.e. you need $1+n$ iterations in each dimension.
...
S = np.zeros((1+timesteps, 1+timesteps)) # Include starting position too (S0).
for i in range(S.shape[1]):
for j in range(S.shape[0]):
S[j,i] = S0*(u**(i-j))*(d**j)
S = np.triu(S)
- The range of possible values of $S(T)$ does correctly increase with $n$. The range is given by $[S_0 d^n, S_0 u^n] = [S_0 e^{-\sigma\sqrt{nT}}, S_0 e^{\sigma\sqrt{nT}}]$. Note that while the range increases, the probability for ending at these extreme values decreases. In fact, according to the Central Limit Theorem, in the limit as $n\to \infty$ the binomial distribution will become the continuous log-Normal distribution, which indeed has infinite positive support $(0,+\infty)$.
- What the CRR binomial model ensures is that the mean and variance of the discrete binomial model matches those of the continuous model. The mean of the stock will match exactly for any $n$, whereas the variance will asymptotically approach the continuous case. The reason for the variance not being exactly matched for any $n$ is that when finding the parameter $u$ by matching the variance, approximations were made by only keeping first-order terms in a Taylor series expansion.
# ====================================================
# === Compare moments to the continuous exact ones ===
# ====================================================
from scipy import stats as stats
# Binomial model for number of down moves
pd = 1-p
dist = stats.binom(n=timesteps, p=pd)
# Stock terminal mean and variance E[S(T)/S0] and V[S(T)/S0]
# 1st moment for S(T)
bin_m1 = dist.expect(lambda k: S[k.astype(int),-1]/S0)
# 2nd moment for S(T)
bin_m2 = dist.expect(lambda k: (S[k.astype(int),-1]/S0)**2)
# Var[S_T] = E[S_T^2] - E[S_T]^2
bin_var = bin_m2 - bin_m1**2
print(f'Binomial Model: E[S/S0]={bin_m1:.4f}, V[S/S0]={bin_var:.4f}')
# Continuous S(T) moments, https://en.wikipedia.org/wiki/Geometric_Brownian_motion#Properties
gbm_m1 = np.exp(r*T)
gbm_var = np.exp(2*r*T)*(np.exp(sigma**2*T)-1)
gbm_m2 = gbm_var + gbm_m1**2
print(f'Continuous GBM: E[S/S0]={gbm_m1:.4f}, V[S/S0]={gbm_var:.4f}')
Binomial Model: E[S/S0]=1.0513, V[S/S0]=0.5218
Continuous GBM: E[S/S0]=1.0513, V[S/S0]=0.5304