Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: calculate resale values in Lifetime class #213

Closed
wants to merge 10 commits into from
4 changes: 2 additions & 2 deletions app/api/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe("POST API Route", () => {
houseAge: 3,
houseBedrooms: 2,
houseType: "D",
maintenancePercentage: 0.02,
maintenancePercentage: 0.019,
});

const householdData = {
Expand Down Expand Up @@ -83,7 +83,7 @@ describe("POST API Route", () => {
houseAge: 3,
houseBedrooms: 2,
houseType: "D",
maintenancePercentage: 0.02,
maintenancePercentage: 0.019,
});

const errorMessage = "Service error";
Expand Down
4 changes: 2 additions & 2 deletions app/components/ui/CalculatorInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ const CalculatorInput = () => {
urlHouseType === "S"
? urlHouseType
: "D", // Default value for house type
maintenancePercentage: [0.015, 0.02, 0.0375].includes(
maintenancePercentage: [0.015, 0.019, 0.025].includes(
Number(urlMaintenancePercentage)
)
? (Number(urlMaintenancePercentage) as 0.015 | 0.02 | 0.0375) // Type assertion
? (Number(urlMaintenancePercentage) as 0.015 | 0.019 | 0.025) // Type assertion
: 0.015,
housePostcode: urlPostcode || "",
houseSize: Number(urlHouseSize) || undefined,
Expand Down
2 changes: 1 addition & 1 deletion app/models/Household.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ beforeEach(() => {
numberOfBedrooms: 2,
age: 10,
size: 88,
maintenancePercentage: 0.02,
maintenancePercentage: 0.019,
newBuildPricePerMetre: 2120,
averageMarketPrice: 218091.58,
itl3: "TLG24",
Expand Down
67 changes: 67 additions & 0 deletions app/models/Lifetime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Lifetime } from "./Lifetime";
import { createTestProperty, createTestLifetime } from "./testHelpers";

let lifetime = createTestLifetime();

beforeEach(() => {
lifetime = createTestLifetime();
})

it("can be instantiated", () => {
expect(lifetime).toBeInstanceOf(Lifetime);
})

it("creates an array with the correct number of years", () => {
expect(lifetime.lifetimeData).toHaveLength(40)
})

it("reduces mortgage payments to 0 after the mortgage term is reached", () => {
expect(lifetime.lifetimeData[35].newbuildHouseMortgageYearly).toBe(0);
expect(lifetime.lifetimeData[34].marketLandMortgageYearly).toBe(0);
expect(lifetime.lifetimeData[33].fairholdLandMortgageYearly).toBe(0);
expect(lifetime.lifetimeData[32].marketLandMortgageYearly).toBe(0);
})

describe("resale values", () => {
it("correctly calculates for a newbuild house", () => {
// Test newbuild (age 0)
lifetime = createTestLifetime({
property: createTestProperty({ age: 0 })
});
expect(lifetime.lifetimeData[0].depreciatedHouseResaleValue).toBe(186560);
});
it("correctly calculates for a 10-year old house", () => { // Test 10-year-old house
lifetime = createTestLifetime({
property: createTestProperty({
age: 10,
newBuildPricePerMetre: 2120,
size: 88
})
});
// Calculate expected depreciation running `calculateDepreciatedBuildPrice()` method on its own
const houseY10 = createTestProperty({
age: 10,
newBuildPricePerMetre: 2120,
size: 88
})
const depreciatedHouseY10 = houseY10.calculateDepreciatedBuildPrice()
expect(lifetime.lifetimeData[0].depreciatedHouseResaleValue).toBe(depreciatedHouseY10);
});
it("depreciates the house over time", () => {
// Test value changes over time
expect(lifetime.lifetimeData[0].depreciatedHouseResaleValue).toBeGreaterThan(
lifetime.lifetimeData[10].depreciatedHouseResaleValue);
})
});


it("correctly ages the house", () => {
lifetime = createTestLifetime({
property: createTestProperty({ age: 10 })
})

expect(lifetime.lifetimeData[0].houseAge).toBe(10);
expect(lifetime.lifetimeData[5].houseAge).toBe(15);
expect(lifetime.lifetimeData[20].houseAge).toBe(30);
expect(lifetime.lifetimeData[39].houseAge).toBe(49);
})
112 changes: 83 additions & 29 deletions app/models/Lifetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export interface LifetimeData {
marketHouseRentYearly: number;
// we will need the below for newbuilds & retrofits, and oldbuilds
// gasBillYearly: number;
depreciatedHouseResaleValue: number;
fairholdLandPurchaseResaleValue: number;
houseAge: number;
[key: number]: number;
}
/**
Expand All @@ -48,53 +51,87 @@ export class Lifetime {
constructor(params: LifetimeParams) {
this.lifetimeData = this.calculateLifetime(params);
}

/**
* The function loops through and calculates all values for period set by yearsForecast,
* pushing the results to the lifetime array (one object per-year)
*/

/**
* The `calculateLifetime` method creates an array and populates it with instances of the `LifetimeData` object.
* It initialises all variables (except for the mortgage values) using values from `params`, before iterating on them in a `for` loop.
* The mortgages are initialised as empty variables and then given values in a `for` loop that ensures that the year is within the `mortgageTerm`
* @param params
* @returns
*/
private calculateLifetime(params: LifetimeParams): LifetimeData[] {
const lifetime: LifetimeData[] = [];

// // initialise properties; all properties with default value of 0 will be updated in the loop
// this.incomeYearly = params.incomeYearly;
// this.affordabilityThresholdIncome = params.incomeYearly * params.affordabilityThresholdIncomePercentage;
// this.newbuildHouseMortgageYearly = 0;
// this.depreciatedHouseMortgageYearly = 0;
// this.fairholdLandMortgageYearly = 0;
// this.marketLandMortgageYearly = 0;
// this.fairholdLandRentYearly = 0;
// this.maintenanceCost = params.property.newBuildPrice * params.maintenanceCostPercentage;
// this.marketLandRentYearly = 0;
// this.marketHouseRentYearly = 0;

// initialise mortgage values
let newbuildHouseMortgageYearlyIterative;
let depreciatedHouseMortgageYearlyIterative;
let fairholdLandMortgageYearlyIterative;
let marketLandMortgageYearlyIterative;

// initialise non-mortgage variables;
/** Increases yearly by `ForecastParameters.incomeGrowthPerYear`*/
let incomeYearlyIterative = params.incomeYearly;
/** 35% (or the value of `affordabilityThresholdIncomePercentage`) multiplied by `incomeYearly`*/
let affordabilityThresholdIncomeIterative =
incomeYearlyIterative * params.affordabilityThresholdIncomePercentage;
/** Increases yearly by `ForecastParameters.propertyPriceGrowthPerYear`*/
let averageMarketPriceIterative = params.property.averageMarketPrice;
/** Increases yearly by `ForecastParameters.constructionPriceGrowthPerYear`*/
let newBuildPriceIterative = params.property.newBuildPrice;
/** Divides `landPrice` by `averageMarketPrice` anew each year as they appreciate */
let landToTotalRatioIterative =
params.property.landPrice / params.property.averageMarketPrice;
/** Subtracts `newBuildPriceIterative` from `averageMarketPriceIterative` */
let landPriceIterative = params.property.landPrice;
/** Increases yearly by `ForecastParameters.rentGrowthPerYear`*/
let marketRentYearlyIterative =
params.marketRent.averageRentMonthly * MONTHS_PER_YEAR;
/** Increases yearly by `ForecastParameters.rentGrowthPerYear`*/
let marketRentAffordabilityIterative =
marketRentYearlyIterative / incomeYearlyIterative
/** Uses the `landToTotalRatioIterative` to calculate the percentage of market rent that goes towards land as the market inflates */
let marketRentLandYearlyIterative =
marketRentYearlyIterative * landToTotalRatioIterative;
/** Subtracts `marketRentLandYearlyIterative` from inflating `marketRentYearlyIterative` */
let marketRentHouseYearlyIterative =
marketRentYearlyIterative - marketRentLandYearlyIterative;
/** The percentage (`ForecastParameters.maintenancePercentage`) is kept steady, and is multiplied by the `newBuildPriceIterative` as it inflates */
let maintenanceCostIterative =
params.maintenancePercentage * newBuildPriceIterative;
/** Each loop a new `Property` instance is created, this is updated by running the `calculateDepreciatedBuildPrice()` method on `iterativeProperty` */
let depreciatedHouseResaleValueIterative = params.property.depreciatedBuildPrice;
/** Resale value increases with `ForecastParameters.constructionPriceGrowthPerYear` */
let fairholdLandPurchaseResaleValueIterative = params.fairholdLandPurchase.discountedLandPrice;
/** Initialises as user input house age and increments by one */
let houseAgeIterative = params.property.age;

// Initialise mortgage variables
/** Assuming a constant interest rate, this figures stays the same until the mortgage term (`marketPurchase.houseMortgage.termYears`) is reached */
let newbuildHouseMortgageYearlyIterative = params.marketPurchase.houseMortgage.yearlyPaymentBreakdown[0].yearlyPayment;
/** Assuming a constant interest rate, this figures stays the same until the mortgage term (`marketPurchase.houseMortgage.termYears`) is reached. `termyears` is the same across tenures, so it doesn't matter that it comes from a `marketPurchase` object here */
let depreciatedHouseMortgageYearlyIterative = params.fairholdLandPurchase.depreciatedHouseMortgage.yearlyPaymentBreakdown[0].yearlyPayment;
/** Assuming a constant interest rate, this figures stays the same until the mortgage term (`marketPurchase.houseMortgage.termYears`) is reached. `termyears` is the same across tenures, so it doesn't matter that it comes from a `marketPurchase` object here */
let fairholdLandMortgageYearlyIterative = params.fairholdLandPurchase.discountedLandMortgage.yearlyPaymentBreakdown[0].yearlyPayment;
/** Assuming a constant interest rate, this figures stays the same until the mortgage term (`marketPurchase.houseMortgage.termYears`) is reached. `termyears` is the same across tenures, so it doesn't matter that it comes from a `marketPurchase` object here */
let marketLandMortgageYearlyIterative = params.marketPurchase.landMortgage.yearlyPaymentBreakdown[0].yearlyPayment;

// New instance of FairholdLandRent class
let fairholdLandRentIterative = new Fairhold({
affordability: marketRentAffordabilityIterative,
landPriceOrRent: marketRentLandYearlyIterative,
}).discountedLandPriceOrRent;

// Push the Y0 values before they start being iterated-upon
lifetime.push({
incomeYearly: incomeYearlyIterative,
affordabilityThresholdIncome: affordabilityThresholdIncomeIterative,
newbuildHouseMortgageYearly: newbuildHouseMortgageYearlyIterative,
depreciatedHouseMortgageYearly: depreciatedHouseMortgageYearlyIterative,
fairholdLandMortgageYearly: fairholdLandMortgageYearlyIterative,
marketLandMortgageYearly: marketLandMortgageYearlyIterative,
fairholdLandRentYearly: fairholdLandRentIterative,
maintenanceCost: maintenanceCostIterative,
marketLandRentYearly: marketRentLandYearlyIterative,
marketHouseRentYearly: marketRentHouseYearlyIterative,
depreciatedHouseResaleValue: depreciatedHouseResaleValueIterative,
fairholdLandPurchaseResaleValue: fairholdLandPurchaseResaleValueIterative,
houseAge: houseAgeIterative,
});

for (let i = 0; i < params.yearsForecast - 1; i++) {
// The 0th round has already been calculated and pushed above
for (let i = 1; i <= params.yearsForecast - 1; i++) {
incomeYearlyIterative =
incomeYearlyIterative * (1 + params.incomeGrowthPerYear);
affordabilityThresholdIncomeIterative =
Expand All @@ -117,6 +154,20 @@ export class Lifetime {
marketRentYearlyIterative - marketRentLandYearlyIterative;
maintenanceCostIterative =
newBuildPriceIterative * params.maintenancePercentage;

/**
* A new instance of the `Property` class is needed each loop in order to
re-calculate the depreciated house value as the house gets older
*/
const iterativeProperty = new Property({
...params.property,
age: houseAgeIterative
});
/* Use the `calculateDepreciatedBuildPrice()` method on the new `Property` class
to calculate an updated depreciated house value */
depreciatedHouseResaleValueIterative = iterativeProperty.calculateDepreciatedBuildPrice()
fairholdLandPurchaseResaleValueIterative = fairholdLandPurchaseResaleValueIterative * (1 + params.constructionPriceGrowthPerYear) // TODO: replace variable name with cpiGrowthPerYear
houseAgeIterative += 1

// If the mortgage term ongoing (if `i` is less than the term), calculate yearly mortgage payments
if (i < params.marketPurchase.houseMortgage.termYears - 1) {
Expand All @@ -132,14 +183,14 @@ export class Lifetime {
marketLandMortgageYearlyIterative = 0;
}

/* A new instance of the Fairhold class is needed here in order to
/* A new instance of the Fairhold class is needed in each loop in order to
re-calculate land rent values per-year (with updating local house prices
and income levels)*/
const fairholdLandRentIterative = new Fairhold({
fairholdLandRentIterative = new Fairhold({
affordability: marketRentAffordabilityIterative,
landPriceOrRent: marketRentLandYearlyIterative,
}).discountedLandPriceOrRent;

lifetime.push({
incomeYearly: incomeYearlyIterative,
affordabilityThresholdIncome: affordabilityThresholdIncomeIterative,
Expand All @@ -151,6 +202,9 @@ export class Lifetime {
maintenanceCost: maintenanceCostIterative,
marketLandRentYearly: marketRentLandYearlyIterative,
marketHouseRentYearly: marketRentHouseYearlyIterative,
depreciatedHouseResaleValue: depreciatedHouseResaleValueIterative,
fairholdLandPurchaseResaleValue: fairholdLandPurchaseResaleValueIterative,
houseAge: houseAgeIterative,
});
}
return lifetime;
Expand Down
25 changes: 12 additions & 13 deletions app/models/Property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ describe('Property', () => {
numberOfBedrooms: 2,
age: 10,
size: 88,
maintenancePercentage: 0.02,
maintenancePercentage: 0.015,
newBuildPricePerMetre: 2120,
averageMarketPrice: 219135,
itl3: "TLJ12",
Expand Down Expand Up @@ -45,7 +45,7 @@ describe('Property', () => {
numberOfBedrooms: 20,
age: 11,
size: 88,
maintenancePercentage: 0.02,
maintenancePercentage: 0.015,
newBuildPricePerMetre: 2120,
averageMarketPrice: 218091.58,
itl3: "TLG24",
Expand All @@ -57,7 +57,7 @@ describe('Property', () => {
numberOfBedrooms: 6,
age: 11,
size: 88,
maintenancePercentage: 0.02,
maintenancePercentage: 0.015,
newBuildPricePerMetre: 2120,
averageMarketPrice: 218091.58,
itl3: "TLG24",
Expand All @@ -71,23 +71,22 @@ describe('Property', () => {
postcode: "WV8 1HG",
houseType: "T",
numberOfBedrooms: 20,
age: 1,
age: 0,
size: 88,
maintenancePercentage: 0.02,
maintenancePercentage: 0.015,
newBuildPricePerMetre: 2120,
averageMarketPrice: 218091.58,
itl3: "TLG24",
});

expect(property.depreciatedBuildPrice).toBe(property.newBuildPrice);
});

describe('depreciation calculations (existing build)', () => {
describe('depreciation calculations (existing build)', () => {
test.each([
['foundations', 39177.6, 1, 0, 39177.6], // component, depreciationPercentageYearly, percentOfMaintenanceYearly, expectedNewComponentValue, expectedDepreciationFactor, expectedMaintenanceAddition, expectedDepreciatedValue
['internalLinings', 7462.4, .68, 1863.74439, 7176.96],
['electricalAppliances', 7462.4, 0.2503, 1863.74439, 3731.57],
['ventilationServices', 7462.4, 0.3997, 1863.74439, 4846.45]
['foundations', 39177.6, 1, 0, 39177.6],
['internalLinings', 7462.4, .68, 2070.816, 7145.248],
['electricalAppliances', 7462.4, 0.167, 2070.816, 3317.04],
['ventilationServices', 7462.4, 0.333, 2070.816, 4555.8]
])('correctly calculates all values for %s', (component, expectedNewComponentValue, expectedDepreciationFactor, expectedMaintenanceAddition, expectedDepreciatedValue) => {
const result = property.calculateComponentValue(
component as keyof HouseBreakdown,
Expand Down Expand Up @@ -128,7 +127,7 @@ describe('Property', () => {
expect(result.depreciatedComponentValue).toBeGreaterThanOrEqual(0);
});

it('should calculate correct depreciation for a 10-year-old house', () => {
expect(property.depreciatedBuildPrice).toBeCloseTo(172976.566);
it('should calculate correct depreciation for a 10-year-old house', () => {
expect(property.depreciatedBuildPrice).toBeCloseTo(171467.3);
});
});
7 changes: 3 additions & 4 deletions app/models/Property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class Property {
this.postcode = params.postcode;
this.houseType = params.houseType;
this.numberOfBedrooms = params.numberOfBedrooms;
this.age = params.age - 1; // Subtract 1 because years should be indexed to 0
this.age = params.age // TODO: update frontend so that newbuild = 0
this.size = params.size;
this.maintenancePercentage = params.maintenancePercentage;
this.newBuildPricePerMetre = params.newBuildPricePerMetre;
Expand All @@ -91,9 +91,8 @@ export class Property {
return newBuildPrice;
}

private calculateDepreciatedBuildPrice() {
if (this.age === 0) return this.newBuildPrice; // If newbuild, return newBuildPrice and don't depreciate

public calculateDepreciatedBuildPrice() {
if (this.age === 0) return this.newBuildPrice;
let depreciatedBuildPrice = 0;

// Calculate for each component using the public method
Expand Down
3 changes: 2 additions & 1 deletion app/models/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const NATIONAL_AVERAGES: NationalAverage = {
* Maintenance levels are percentages (represented as decimals),
* figures from our own model
*/
export const MAINTENANCE_LEVELS = [0.015, 0.02, 0.0375] as const;
export const MAINTENANCE_LEVELS = [0.015, 0.019, 0.025] as const;

type Component =
| "foundations"
Expand All @@ -56,6 +56,7 @@ type Component =

interface ComponentBreakdown {
percentageOfHouse: number;
/** This is the percentage of the component value that depreciates yearly, not total house value. */
depreciationPercentageYearly: number;
percentOfMaintenanceYearly: number;
}
Expand Down
Loading
Loading