"""Tests du chapitre 41 — PHMU, terrains et développement (DONNÉES SYNTHÉTIQUES du cas 1 ; zonage FICTIF ; hypothèses déclarées). Tolérances : 1 $ sur les montants cités arrondis au dollar ; 0,05 point sur les pourcentages à une décimale ; 1e-9 sur les identités. Les valeurs « citées » sont celles de manuscript/chapters/ch41.tex et de solutions/ch41.tex.""" import sys, pathlib, math import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch41_phmu as cas import ch08_zonage as c08 from traite import finance as fin TOL = 1.0 @pytest.fixture(scope="module") def R(): return cas.exemple_complet() class TestIntrants: def test_couts_ch21(self, R): cu = R["couts"] assert cu["direct"] == pytest.approx(298826.17, abs=0.01) and cu["indirects"] == pytest.approx(62598.68, abs=0.01) assert cu["cout_neuf"] == pytest.approx(397567.33, abs=0.01) assert cu["di_m2_maison"] == pytest.approx(2190.45, abs=0.01) assert cu["ratio_ipcb_plex"] == pytest.approx(118.5 / 116.5, rel=1e-12) and round(cu["ratio_ipcb_plex"], 4) == 1.0172 assert cu["di_m2_plex"] == pytest.approx(2228.06, abs=0.01) def test_prix_sortie(self, R): u = R["usages"] assert u["S0"]["vente"]["prix_isolee_equiv"] == pytest.approx(637953, abs=TOL) # prédiction hédonique du sujet (ch. 18) assert u["U"]["vente"]["gdv"] == pytest.approx(720727, abs=TOL) assert u["D"]["vente"]["prix_isolee_equiv"] == pytest.approx(550726, abs=TOL) and u["D"]["vente"]["prix_unite"] == pytest.approx(495654, abs=TOL) assert u["D"]["vente"]["gdv"] == pytest.approx(991307, abs=TOL) assert u["T"]["vente"]["prix_isolee_equiv"] == pytest.approx(480573, abs=TOL) and u["T"]["vente"]["prix_unite"] == pytest.approx(408487, abs=TOL) assert u["T"]["vente"]["gdv"] == pytest.approx(1225460, abs=TOL) assert u["Q"]["vente"]["gdv"] == pytest.approx(1371384, abs=TOL) assert u["D"]["vente"]["prix_unite"] == pytest.approx(0.90 * u["D"]["vente"]["prix_isolee_equiv"], rel=1e-12) assert u["T"]["vente"]["prix_unite"] == pytest.approx(0.85 * u["T"]["vente"]["prix_isolee_equiv"], rel=1e-12) def test_en_bloc_cas2(self, R): tb = R["usages"]["T_bloc"] assert tb["prix_par_logement_triplex"] == pytest.approx(281000, abs=TOL) and tb["gdv"] == pytest.approx(843000, abs=TOL) assert tb["promoteur"]["valeur_residuelle"] == pytest.approx(-28102, abs=TOL) and tb["promoteur"]["reste"] < 0 class TestQuatreTests: def test_legal(self, R): u = R["usages"] assert u["U"]["legal"]["logements_admissibles"] == 3 and u["U"]["legal"]["aire_max_m2"] == pytest.approx(372.0) assert u["D"]["legal"]["aire_brute_m2"] == pytest.approx(240 / 0.85) and u["D"]["legal"]["permis"] assert u["T"]["legal"]["aire_brute_m2"] == pytest.approx(300 / 0.85) and u["T"]["legal"]["permis"] assert not u["Q"]["legal"]["permis"] and not u["Q"]["legal"]["ok_cos"] and not u["Q"]["legal"]["ok_logements"] assert c08.densite_admissible(620, 150, 3, 0.60, 450)["logements_par_densite"] == 4 # la densité seule admettrait 4 ; le plafond de la grille (3) l'emporte def test_physique(self, R): for k in ("U", "D", "T"): tp = R["usages"][k]["physique"] assert tp["possible"] and tp["etages_requis"] <= 3 and tp["empreinte_max_m2"] == pytest.approx(16 * 16) class TestResiduel: def test_valeurs_citees_promoteur(self, R): u = R["usages"] attendu = {"U": (158849, 185550, 6814, 108109, 28829), "D": (190541, 225936, 12290, 148696, 39652), "T": (229265, 275967, 18239, 183819, 49018), "Q": (244935, 299288, 23994, 205708, 54855)} for k, (vl, reste, F, marge, frais) in attendu.items(): r = u[k]["promoteur"] assert r["valeur_residuelle"] == pytest.approx(vl, abs=TOL) and r["reste"] == pytest.approx(reste, abs=TOL) assert r["financement"] == pytest.approx(F, abs=TOL) and r["marge"] == pytest.approx(marge, abs=TOL) and r["frais_vente"] == pytest.approx(frais, abs=TOL) assert u["T"]["promoteur"]["couts_construction"] == pytest.approx(668417, abs=TOL) and u["T"]["promoteur"]["contribution_parcs"] == pytest.approx(22927, abs=TOL) assert u["T"]["promoteur"]["v_l_statique"] == pytest.approx(250879, abs=TOL) and u["T"]["promoteur"]["facteur_temps"] == pytest.approx(1.0619 ** 1.5, rel=1e-12) assert u["D"]["promoteur"]["couts_construction"] == pytest.approx(534734, abs=TOL) and u["U"]["promoteur"]["couts_construction"] == pytest.approx(361425, abs=TOL) def test_valeurs_citees_occupant(self, R): u = R["usages"] assert u["U"]["occupant"]["valeur_residuelle"] == pytest.approx(266187, abs=TOL) and u["U"]["occupant"]["marge"] == pytest.approx(39824, abs=TOL) assert u["D"]["occupant"]["valeur_residuelle"] == pytest.approx(330792, abs=TOL) and u["T"]["occupant"]["valeur_residuelle"] == pytest.approx(399479, abs=TOL) # marge sur le coût = 10 % de (C + D + F) r = u["U"]["occupant"]; assert r["marge"] == pytest.approx(0.10 * (r["couts_construction"] + r["demolition"] + r["financement"]), rel=1e-12) def test_identites(self, R): r = R["usages"]["T"]["promoteur"]; h = cas.HYP # reste = GDV − frais − C − D − F − marge ; V_L(1+τ)(1+r)^T = reste ; parcs = τ V_L assert r["reste"] == pytest.approx(r["gdv"] - r["frais_vente"] - r["couts_construction"] - r["demolition"] - r["financement"] - r["marge"], rel=1e-12) assert r["valeur_residuelle"] * (1 + h["taux_parcs"]) * r["facteur_temps"] == pytest.approx(r["reste"], rel=1e-12) assert r["contribution_parcs"] == pytest.approx(h["taux_parcs"] * r["valeur_residuelle"], rel=1e-12) # intérêts intérimaires : I = L i T_eff / 2 avec T_eff = T(1 − part mise de fonds) L = h["part_pret"] * (r["couts_construction"] + r["demolition"]); T_eff = 1.5 * (1 - (1 - h["part_pret"])) assert r["financement"] == pytest.approx(L * h["taux_pret"] * T_eff / 2, rel=1e-12) # forme linéaire V_L = a·GDV − b′ et seuil a, b_prime = cas._a_b(r, h) assert a * r["gdv"] - b_prime == pytest.approx(r["valeur_residuelle"], rel=1e-10) s = cas.seuil_gdv(r, R["v_act"]); r2 = cas.residuel_developpement(s, 300.0, R["couts"]["di_m2_plex"], 1.5, h) assert r2["valeur_residuelle"] == pytest.approx(R["v_act"], rel=1e-9) def test_version_statique_egale_ch08(self, R): """Avec r = 0, marge et financement en % du GDV et frais de vente nuls, la fonction retrouve valeur_residuelle_redeveloppement du ch. 8.""" gdv, s_tot, di = 1_000_000.0, 300.0, 2000.0 h = {**cas.HYP, "frais_vente": 0.0, "r_portage": 0.0} r = cas.residuel_developpement(gdv, s_tot, di, 1.0, h) ref = c08.valeur_residuelle_redeveloppement(3, 100.0, gdv / 300.0, di, 0.0, h["demolition"], h["marge"], r["financement"] / gdv, h["taux_parcs"]) assert r["valeur_residuelle"] == pytest.approx(ref["valeur_residuelle"], rel=1e-10) def test_seuils_et_conclusion(self, R): assert R["seuil_gdv_T"] == pytest.approx(1776390, abs=TOL) and R["hausse_requise_T"] * 100 == pytest.approx(45.0, abs=0.05) assert R["seuil_gdv_T_marche"] == pytest.approx(1296396, abs=TOL) and R["hausse_requise_T_marche"] * 100 == pytest.approx(5.8, abs=0.05) assert R["phmu_vacant"] == "U" and R["phmu_vacant_promoteur"] == "T" and R["phmu_ameliore"] == "S0" assert R["contribution_batiment"] == pytest.approx(323000) and all(p == 0 for p in R["primes"].values()) def test_sensibilite_citee(self, R): s = R["sensibilite"].set_index("parametre") assert s.loc["Prix des unités", "bas"] == pytest.approx(105569, abs=TOL) and s.loc["Prix des unités", "haut"] == pytest.approx(352962, abs=TOL) assert s.loc["Coût de construction", "bas"] == pytest.approx(286246, abs=TOL) and s.loc["Coût de construction", "haut"] == pytest.approx(172285, abs=TOL) assert s.loc["Marge du promoteur", "bas"] == pytest.approx(280169, abs=TOL) and s.loc["Marge du promoteur", "haut"] == pytest.approx(178362, abs=TOL) assert s.loc["Durée totale", "bas"] == pytest.approx(241459, abs=TOL) and s.loc["Durée totale", "haut"] == pytest.approx(217582, abs=TOL) assert s.loc["Taux (prêt et portage)", "bas"] == pytest.approx(236137, abs=TOL) and s.loc["Taux (prêt et portage)", "haut"] == pytest.approx(221939, abs=TOL) assert s.loc["Contribution pour fins de parcs", "bas"] == pytest.approx(252192, abs=TOL) assert s.loc["Frais de vente", "bas"] == pytest.approx(249627, abs=TOL) and s.loc["Frais de vente", "haut"] == pytest.approx(208904, abs=TOL) # la hausse de prix de 15 % fait passer le triplex (promoteur) au-dessus de la maison neuve (occupant) : bascule de la PHMU du terrain vacant assert s.loc["Prix des unités", "haut"] > R["usages"]["U"]["occupant"]["valeur_residuelle"] > s.loc["Prix des unités", "base"] class TestOption: def test_parametres(self, R): o = R["option"] assert o["u"] == pytest.approx(math.exp(0.10 * math.sqrt(5)), rel=1e-12) and o["d"] == pytest.approx(1 / o["u"], rel=1e-12) assert round(o["u"], 4) == 1.2506 and round(o["d"], 4) == 0.7996 and round(o["F"], 4) == 0.8860 and round(o["q"], 4) == 0.1915 and round(o["disc"], 4) == 0.9057 assert 0 < o["q"] < 1 and o["F"] == pytest.approx(((1.02) / 1.045) ** 5, rel=1e-12) assert round(R["a"], 4) == 0.6729 and R["b"] == pytest.approx(1195378, abs=TOL) def test_noeuds_cites(self, R): n = R["option"]["noeuds"] assert n[(1, 1)]["gdv"] == pytest.approx(1532535, abs=TOL) and n[(1, 0)]["gdv"] == pytest.approx(979914, abs=TOL) assert n[(2, 2)]["gdv"] == pytest.approx(1916556, abs=TOL) and n[(2, 1)]["gdv"] == pytest.approx(1225460, abs=TOL) and n[(2, 0)]["gdv"] == pytest.approx(783568, abs=TOL) assert n[(2, 2)]["exercice"] == pytest.approx(94322, abs=TOL) and n[(2, 1)]["exercice"] == 0 and n[(2, 0)]["exercice"] == 0 assert n[(1, 1)]["continuation"] == pytest.approx(16357, abs=TOL) and n[(1, 1)]["exercice"] == 0 and not n[(1, 1)]["exercer"] assert n[(0, 0)]["valeur"] == pytest.approx(2837, abs=TOL) def test_prime_et_identites(self, R): o = R["option"] assert o["prime_americaine"] == pytest.approx(2837, abs=TOL) and o["prime_europeenne"] == pytest.approx(o["prime_americaine"], rel=1e-12) assert o["van_immediate"] == 0.0 and o["prime_americaine"] >= o["van_immediate"] # valeur actualisée risque-neutre du seul nœud dans la monnaie : q² × 94 322 × disc² assert o["prime_americaine"] == pytest.approx(o["q"] ** 2 * o["noeuds"][(2, 2)]["exercice"] * o["disc"] ** 2, rel=1e-10) assert o["gdv_seuil_van"] == pytest.approx(R["seuil_gdv_T"], rel=1e-10) assert R["frontiere_t1"] == pytest.approx(1874959, abs=TOL) and R["frontiere_t1"] > o["gdv_seuil_van"] assert o["prime_americaine"] / R["v_act"] * 100 == pytest.approx(0.47, abs=0.005) def test_sensibilite_option(self, R): s = R["sens_option"] assert s[600000.0][0.05] == 0.0 and s[600000.0][0.10] == pytest.approx(2837, abs=TOL) and s[600000.0][0.15] == pytest.approx(21420, abs=TOL) assert s[600000.0][0.20] == pytest.approx(47939, abs=TOL) and s[600000.0][0.25] == pytest.approx(77556, abs=TOL) assert s[450000.0][0.10] == pytest.approx(7348, abs=TOL) and s[300000.0][0.10] == pytest.approx(23568, abs=TOL) and s[300000.0][0.25] == pytest.approx(132208, abs=TOL) for v in s: # la prime croît avec σ et décroît avec la valeur en usage vals = list(s[v].values()); assert all(b >= a for a, b in zip(vals, vals[1:])) assert s[300000.0][0.15] > s[450000.0][0.15] > s[600000.0][0.15] def test_americaine_superieure_europeenne_quand_exercice_anticipe(self): """Avec un rendement de détention élevé et une forte volatilité, l'exercice anticipé a de la valeur.""" o = cas.option_redeveloppement(1_000_000, 0.7, 500_000, 0.30, 5.0, 0.02, 0.08, 2) assert o["prime_americaine"] >= o["prime_europeenne"] and o["prime_americaine"] >= o["van_immediate"] assert any(nd.get("exercer") for nd in o["noeuds"].values()) def test_frontiere_coherente(self): a, b = 0.7, 1_000_000.0 f = cas.frontiere_exercice_t1(a, b, 0.20, 5.0, 0.02, 0.045) p = cas.parametres_binomiaux(0.20, 5.0, 0.02, 0.045) cont = p["disc"] * (p["q"] * max(a * f * p["u"] - b, 0) + (1 - p["q"]) * max(a * f * p["d"] - b, 0)) assert a * f - b == pytest.approx(cont, rel=1e-6) and f >= b / a class TestCapozzaHelsley: def test_valeurs_citees(self, R): ch = R["capozza"]; cr = ch["resultat"] assert ch["R_A"] == pytest.approx(27000, abs=TOL) and ch["R_T"] == pytest.approx(55146, abs=TOL) and ch["C"] == pytest.approx(716656, abs=TOL) assert cr["R_certitude"] == pytest.approx(59250, abs=TOL) and cr["prime_irreversibilite"] == pytest.approx(13249, abs=TOL) and cr["R_seuil"] == pytest.approx(72498, abs=TOL) assert ch["hausse_requise_certitude"] * 100 == pytest.approx(7.4, abs=0.05) and ch["hausse_requise_incertitude"] * 100 == pytest.approx(31.5, abs=0.05) def test_limite_certitude_et_racine(self): r, g, s = 0.045, 551.0, 5515.0 out = cas.seuil_capozza_helsley(27000, 716656, r, g, s); al = out["alpha"] assert 0.5 * s * s * al * al + g * al - r == pytest.approx(0, abs=1e-9) # α racine positive de ½σ²α² + gα − r = 0 assert cas.seuil_capozza_helsley(27000, 716656, r, g, 0.0)["R_seuil"] == pytest.approx(27000 + r * 716656, rel=1e-12) assert cas.seuil_capozza_helsley(27000, 716656, r, g, 1e-3)["prime_irreversibilite"] == pytest.approx(0.0, abs=1e-6) assert out["prime_irreversibilite"] > 0 and cas.seuil_capozza_helsley(27000, 716656, r, g, 2 * s)["prime_irreversibilite"] > out["prime_irreversibilite"] class TestExercices: """Chiffres des solutions (solutions/ch41.tex).""" def test_ex2_residuel_statique(self): # 6 logements de 95 m² à 4 900 $/m² ; coûts 2 300 $/m² ; indirects 15 % ; démolition 45 000 $ ; marge 15 % ; financement 3 % ; parcs 10 % r = c08.valeur_residuelle_redeveloppement(6, 95.0, 4900.0, 2300.0, 0.15, 45000.0, 0.15, 0.03, 0.10) assert r["gdv"] == pytest.approx(2793000) and r["couts_directs"] == pytest.approx(1311000) and r["couts_indirects"] == pytest.approx(196650) assert r["reste_avant_parcs"] == pytest.approx(737610) and r["valeur_residuelle"] == pytest.approx(670554.5, abs=0.1) # (b) sans parcs ni financement : 737 610 + 83 790 = 821 400 r2 = c08.valeur_residuelle_redeveloppement(6, 95.0, 4900.0, 2300.0, 0.15, 45000.0, 0.15, 0.0, 0.0) assert r2["valeur_residuelle"] == pytest.approx(821400) def test_ex3_calendrier(self): h = {**cas.HYP, "demolition": 0.0} r = cas.residuel_developpement(1_800_000, 400.0, 2300.0, 2.0, h) assert r["couts_construction"] == pytest.approx(920000) and r["pret"] == pytest.approx(690000) assert r["financement"] == pytest.approx(690000 * 0.0619 * 1.5 / 2, rel=1e-12) and r["financement"] == pytest.approx(32033.25, abs=0.01) assert r["reste"] == pytest.approx(1800000 - 72000 - 920000 - 32033.25 - 270000, abs=0.01) assert r["valeur_residuelle"] == pytest.approx(505966.75 / (1.1 * 1.0619 ** 2), rel=1e-9) and r["valeur_residuelle"] == pytest.approx(407908, abs=TOL) r0 = cas.residuel_developpement(1_800_000, 400.0, 2300.0, 2.0, {**h, "r_portage": 0.0}) assert r0["valeur_residuelle"] == pytest.approx(459970, abs=TOL) def test_ex5_binomial(self): o = cas.option_redeveloppement(1_000_000, 0.70, 800_000, 0.15, 5.0, 0.02, 0.04, 2) assert round(o["u"], 4) == 1.3985 and round(o["d"], 4) == 0.7150 and round(o["q"], 4) == 0.2815 and round(o["F"], 4) == 0.9075 assert o["noeuds"][(2, 2)]["exercice"] == pytest.approx(0.70 * 1_000_000 * o["u"] ** 2 - 800_000, rel=1e-12) and o["noeuds"][(2, 2)]["exercice"] == pytest.approx(569089, abs=TOL) assert o["prime_americaine"] == pytest.approx(45636, abs=TOL) and o["prime_europeenne"] == pytest.approx(37007, abs=TOL) and o["van_immediate"] == 0.0 assert o["noeuds"][(1, 1)]["exercice"] == pytest.approx(178960, abs=TOL) and o["noeuds"][(1, 1)]["continuation"] == pytest.approx(145121, abs=TOL) assert o["noeuds"][(1, 1)]["exercer"] # exercer vaut plus qu'attendre au nœud haut : option américaine > européenne assert o["gdv_seuil_van"] == pytest.approx(800000 / 0.70, rel=1e-12) def test_ex6_capozza(self): out = cas.seuil_capozza_helsley(18000, 600000, 0.05, 400, 4000) assert out["R_certitude"] == pytest.approx(48000) and out["prime_irreversibilite"] == pytest.approx(9266, abs=TOL) and out["R_seuil"] == pytest.approx(57266, abs=TOL) assert out["R_seuil"] / 42000 - 1 == pytest.approx(0.3635, abs=5e-4) # rente courante de 42 000 $ : hausse requise de 36,3 % def test_ex4_teardown(self): # prix 455 000 $ ; démolition 28 000 $ ; valeur du terrain extraite = 483 000 $ ; contribution du bâtiment −28 000 $ assert 455000 + 28000 == 483000 # ratio V_L / P = 1,06 > 1 ; part du bâtiment négative assert (455000 - 483000) / 455000 == pytest.approx(-0.0615, abs=1e-4) def test_marges(self): assert fin.marge_sur_prix(0.10) == pytest.approx(1 / 11) and fin.marge_sur_cout(0.15) == pytest.approx(0.15 / 0.85)