1"""Seller value function: existence/uniqueness, Proposition 1 and 2 comparative statics, boundary cases."""2import numpy as np3import pytest45from src.model import Pareto, Exponential6from src.solver import seller_value, solve_sellers789def test_residual_and_uniqueness(params):10 sb = solve_sellers(8.0, params.k, params)11 assert np.abs(sb.resid).max() < 1e-1012 # Phi is strictly decreasing: perturbing V changes the sign of the residual13 for i, c in enumerate(params.c):14 V = sb.V[i]15 up = -c + params.delta * params.w0 + 8.0 * params.beta * float(params.G.H(V + 1e-3 + params.k)) - (params.r + params.delta) * (V + 1e-3)16 dn = -c + params.delta * params.w0 + 8.0 * params.beta * float(params.G.H(V - 1e-3 + params.k)) - (params.r + params.delta) * (V - 1e-3)17 assert up < 0 < dn181920def test_prop1_monotonicity(params):21 sb = solve_sellers(8.0, params.k, params)22 assert np.all(np.diff(sb.V) < 0) # reservation price falls with c23 assert np.all(np.diff(sb.eta) > 0) # hazard rises with c24 assert np.all(np.diff(sb.pbar) < 0) # expected price falls with c25 # analytic slope V'(c) = -1/[(r+delta) + Lam beta (1-G)] vs finite differences26 slope_an = -1.0 / ((params.r + params.delta) + 8.0 * params.beta * sb.acc)27 slope_fd = np.gradient(sb.V, params.c)28 assert np.allclose(slope_an[1:-1], slope_fd[1:-1], rtol=0.05)29 # |pbar'| <= |V'| under IFR30 assert np.all(np.abs(np.diff(sb.pbar)) <= np.abs(np.diff(sb.V)) + 1e-12)31 assert np.all(-1.0 / (params.r + params.delta) < slope_an) and np.all(slope_an < 0)323334def test_prop1_participation_threshold(params):35 Lam = 8.036 cbar = Lam * params.beta * float(params.G.H(params.w0 + params.k)) - params.r * params.w037 p = params.copy(c=np.array([cbar - 1e-4, cbar + 1e-4]), f=np.array([0.5, 0.5]))38 sb = solve_sellers(Lam, params.k, p)39 assert sb.V[0] >= params.w0 - 1e-9 and sb.V[1] < params.w0404142def test_prop2_arrival_rate(params):43 lams = np.array([2.0, 4.0, 8.0, 16.0])44 V = np.array([solve_sellers(L, params.k, params).V for L in lams])45 eta = np.array([solve_sellers(L, params.k, params).eta for L in lams])46 pbar = np.array([solve_sellers(L, params.k, params).pbar for L in lams])47 assert np.all(np.diff(V, axis=0) > 0)48 assert np.all(np.diff(pbar, axis=0) > 0)49 assert np.all(np.diff(eta, axis=0) > 0) # IFR => hazard increasing in Lam_s50 gap = V[:, 0] - V[:, -1]51 assert np.all(np.diff(gap) < 0) # reservation gap narrows with Lam_s52 # analytic dV/dLam vs finite difference53 sb = solve_sellers(8.0, params.k, params)54 an = params.beta * sb.H / ((params.r + params.delta) + 8.0 * params.beta * sb.acc)55 fd = (solve_sellers(8.0 + 1e-4, params.k, params).V - solve_sellers(8.0 - 1e-4, params.k, params).V) / 2e-456 assert np.allclose(an, fd, rtol=1e-4)575859def test_prop2_pareto_counterexample(params):60 """With Pareto G (DFR), d eta / d Lam_s < 0 iff c > delta*w0 + (r+delta)*U, independently of Lam_s (Proposition 2(ii))."""61 lams = (2.0, 5.0, 10.0, 20.0, 40.0)62 for w0, c in ((0.0, 0.05), (0.8, 0.2)): # condition holds -> hazard decreasing in Lam_s63 p = params.copy(G=Pareto(a=1.5, lo=0.7), c=np.array([c]), f=np.array([1.0]), delta=0.1, w0=w0)64 assert c > p.delta * w0 + (p.r + p.delta) * p.k65 eta = np.array([solve_sellers(L, p.k, p).eta[0] for L in lams])66 assert np.all(np.diff(eta) < 0)67 for w0, c in ((0.0, 0.002), (0.8, 0.05)): # condition fails -> hazard increasing68 p = params.copy(G=Pareto(a=1.5, lo=0.7), c=np.array([c]), f=np.array([1.0]), delta=0.1, w0=w0)69 assert c < p.delta * w0 + (p.r + p.delta) * p.k70 eta = np.array([solve_sellers(L, p.k, p).eta[0] for L in lams])71 assert np.all(np.diff(eta) > 0)727374def test_prop6_exponential_elasticity(params):75 """epsilon(c) = (r+delta)/(r+delta+beta*eta) for exponential G."""76 p = params.copy(G=Exponential(rate=8.0, lo=0.7))77 L = 8.078 sb = solve_sellers(L, p.k, p)79 fd = (np.log(solve_sellers(L * 1.001, p.k, p).eta) - np.log(solve_sellers(L / 1.001, p.k, p).eta)) / (2 * np.log(1.001))80 an = (p.r + p.delta) / ((p.r + p.delta) + p.beta * sb.eta)81 assert np.allclose(fd, an, rtol=2e-3)82 assert np.all(np.diff(an) < 0)838485def test_boundary_cases(params):86 # zero urgency, no withdrawal: V solves r V = Lam beta H(V+k)87 p = params.copy(c=np.array([0.0]), f=np.array([1.0]), delta=0.0)88 sb = solve_sellers(8.0, p.k, p)89 assert abs(p.r * sb.V[0] - 8.0 * p.beta * float(p.G.H(sb.V[0] + p.k))) < 1e-1090 # frozen market: Lam -> 0 gives V -> (-c + delta w0)/(r+delta)91 sbf = solve_sellers(1e-9, params.k, params)92 assert np.allclose(sbf.V, (-params.c + params.delta * params.w0) / (params.r + params.delta), atol=1e-7)93 # very liquid market: v* -> w_H, prices -> w_H - k94 sbl = solve_sellers(1e7, params.k, params)95 assert np.all(sbl.vstar > params.G.hi - 1e-2)96 assert np.all(np.abs(sbl.pbar - (params.G.hi - params.k)) < 2e-2)97 # survival and hazards98 sb = solve_sellers(8.0, params.k, params)99 assert np.all(sb.eta >= 0) and np.all(sb.acc >= 0) and np.all(sb.acc <= 1)100 tau = np.linspace(0, 3, 50)101 surv = np.exp(-np.outer(tau, sb.eta + params.delta))102 assert np.all(surv <= 1) and np.all(np.diff(surv, axis=0) <= 0)103104105def test_lemma3_spurious_duration_dependence(params):106 sb = solve_sellers(8.0, params.k, params)107 mu = sb.eta + params.delta108 tau = np.linspace(0, 2, 400)109 S = np.exp(-np.outer(tau, mu)) @ params.f110 hazard = -np.gradient(np.log(S), tau)111 assert np.all(np.diff(hazard)[5:-5] < 0)112