Mortgage Portfolio Tutorial

Last updated on 16th July 2024

In this tutorial, you will learn how to build a more complex model than the simple trading model demonstrated in the introductory tutorial. You will also be taught some more advanced modelling concepts and explore best practices for the iterative development of models.

The model is a simulation of a commercial bank's mortgage portfolio. Mortgages are lent to households, who are hit by income shocks, earn income, pay taxes, consume, and attempt to repay their mortgage. Households who are unable to make their repayments eventually default on their mortgage, and the mortgage contract is written off the bank's balance sheet.

This tutorial is divided into three parts. This reflects the iterative nature of building agent-based models. When we are done, we should have the below model displayed on the console.

The code for this tutorial is available as a zip download, containing a complete model as a Maven project. This project should be able to be run from any Maven or Java IDE environment.

Download Tutorial Files

mortgage model single run

Skeleton Model

In the first installment of this three-part tutorial, we will sketch out a simplified skeleton model. That skeleton model will introduce simple agents with limited behaviour. Parts two and three will add flesh to these bones.

Basic Model Structure

  • Two types of agent: Households and Bank
  • One household, one bank
  • Mortgage made by the bank
  • Household earns income, consumes, and repays mortgage

The Main Model Class

The main model class registers agents, registers links, and creates accumulators. It sets up the agent groups and connects them together. It also specifies the variables that will be output to the console and the control parameters that can be tweaked between model runs.

The logic of the model, which describes the sequence of events that takes place within one simulated time step, is written in a sequence at the bottom of this file.

The behaviours taking place for each agent at each step are captured in methods which belong to the agent classes. For example, the bank's methods are found in the Bank class, while the household's methods are found in the Household class. Writing the flow sequence in this way keeps it terse and easy to read. The natural logic of the model is thus easy to follow and edit.

MortgageModel.java

import simudyne.core.abm.Action;
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.annotations.Input;
import simudyne.core.graph.LongAccumulator;

public class MortgageModel extends AgentBasedModel<GlobalState> {

  @Input(name = "Number of Households")
  public long nbHouseholds = 100;

  @Override
  public void init() {
    createLongAccumulator("equity", "Bank Equity (£)");
    createLongAccumulator("assets", "Assets");

    registerAgentTypes(Household.class, Bank.class);
    registerLinkTypes(Links.BankLink.class);
  }

  @Override
  public void setup() {
    // Create our agent groups
    Group<Household> householdGroup = generateGroup(Household.class, nbHouseholds);
    Group<Bank> bankGroup = generateGroup(Bank.class, 1);

    // Each household is connected to 1 bank
    householdGroup.partitionConnected(bankGroup, Links.BankLink.class);

    super.setup();
  }

  @Override
  public void step() {
    super.step();

    run(
        Action.create(
            Household.class,
            household -> {
              household.earnIncome();
              household.consume();
              household.payMortgage();
            }),
        Action.create(Bank.class, Bank::updateBalanceSheet));
  }
}

The Bank Class

The Bank class describes stylised data and behaviours of a commercial bank. In this skeleton model, the bank's balance sheet is comprised of debt, assets and equity, which is calculated as assets minus debt. The bank updates its assets in line with the sum of mortgage repayments it receives.

Bank.java

import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
import simudyne.core.annotations.Variable;

import java.util.List;

public class Bank extends Agent<GlobalState> {

  @Variable public int debt = 90;

  void updateBalanceSheet() {
    int assets = 0;
    List<Messages.RepaymentAmount> paymentMessages =
        getMessagesOfType(Messages.RepaymentAmount.class);
    for (Messages.RepaymentAmount payment : paymentMessages) {
      assets += payment.getBody();
    }

    getLongAccumulator("assets").add(assets);
    getLongAccumulator("equity").add(assets - this.debt);
  }
}

The Household Class

The Household class describes the stylised data and behaviours of a household with a mortgage. In the skeleton model, the household earns income, consumes, and compares its stock of wealth with its required mortgage repayment. If it can afford to pay its mortgage, the household sends a message to the bank containing its mortgage repayment.

Household.java

import simudyne.core.abm.Agent;
import simudyne.core.abm.GlobalState;
import simudyne.core.annotations.Variable;

public class Household extends Agent<GlobalState> {

  @Variable public int income = 5000;
  @Variable public int consumption = 3000;
  @Variable public int wealth = 1000;
  @Variable public int repayment = 100;

  void consume() {
    wealth -= consumption;
  }

  void earnIncome() {
    wealth += income;
  }

  void payMortgage() {
    if (canPay()) {
      getLinks(Links.BankLink.class).send(Messages.RepaymentAmount.class, repayment);
    }
  }

  private Boolean canPay() {
    return wealth >= repayment;
  }
}

Complex Behaviours

In this iteration of the model, we have introduced a more extensive set of agent behaviours -- as described by the two sequences at the bottom of the MortgageModel class. Households actively apply for mortgages, and Banks check that a mortgage application meets certain requirements before issuing it. Significantly more output is reported to the console. In this stage of model development, it is useful to track a large number of variables to ensure that the model is generating sensible intermediate and final outputs.

The other significant change in this version, compared to the last version, is the collection of the messages together inside an object (Messages). This is a stylistic choice, but one we recommend as a best practice -- see Class Organisation.

MortgageModel.java

import org.apache.commons.math3.random.EmpiricalDistribution;
import simudyne.core.abm.Action;
import simudyne.core.abm.AgentBasedModel;
import simudyne.core.abm.GlobalState;
import simudyne.core.abm.Group;
import simudyne.core.annotations.Input;
import simudyne.core.annotations.ModelSettings;
import simudyne.core.data.CSVSource;

@ModelSettings(macroStep = 120)
public class MortgageModel extends AgentBasedModel<MortgageModel.Globals> {

  @Input(name = "Number of Households")
  public long nbHouseholds = 100;

  private int wealth = 50000;

  public static final class Globals extends GlobalState {
    @Input(name = "LTI Limit")
    public double LTILimit = 4.5;

    @Input(name = "LTV Limit")
    public double LTVLimit = 0.95;

    @Input(name = "Interest Rate (%)")
    public double interestRate = 5.0;

    @Input(name = "Top Rate Tax Threshold (£k)")
    public int topRateThreshold = 45;

    @Input(name = "Personal Allowance (£k)")
    public int personalAllowance = 11;

    @Input(name = "Basic Rate of Tax (%)")
    public double basicRate = 20.0;

    @Input(name = "Top Rate of Tax (%)")
    public double topRate = 40.0;

    @Input(name = "Income Volatility (%)")
    public double incomeVolatility = 2.5;
  }

  @Override
  public void init() {
    createLongAccumulator("equity", "Bank Equity (£)");
    createLongAccumulator("badLoans", "Bad Loans");
    createLongAccumulator("writeOffs", "Write-offs");
    createLongAccumulator("impairments", "Impairments (£k)");
    createLongAccumulator("debt", "Debt");
    createLongAccumulator("income", "Income");
    createLongAccumulator("mortgages", "Mortgages");
    createLongAccumulator("assets", "Assets");

    registerAgentTypes(Household.class, Bank.class);
    registerLinkTypes(Links.BankLink.class);
  }

  @Override
  public void setup() {
    EmpiricalDistribution incomeDist =
        getContext().getPrng().empiricalFromSource(new CSVSource("data/income-distribution.csv"));

    Group<Household> householdGroup =
        generateGroup(
            Household.class,
            nbHouseholds,
            house -> {
              house.income = (int) incomeDist.sample(1)[0];
              house.wealth = wealth;
            });

    Group<Bank> bankGroup = generateGroup(Bank.class, 1);

    householdGroup.partitionConnected(bankGroup, Links.BankLink.class);

    super.setup();
  }

  @Override
  public void step() {
    super.step();

    run(Household.applyForMortgage, Bank.processApplication, Household.takeOutMortgage);

    run(
        Action.create(
            Household.class,
            (Household h) -> {
              h.incomeShock();
              h.earnIncome();
              h.payTax();
              h.subsistenceConsumption();
              h.payMortgage();
              h.discretionaryConsumption();
            }),
        Action.create(
            Bank.class,
            (Bank b) -> {
              b.accumulateIncome();
              b.processArrears();
              b.clearPaidMortgages();
              b.updateAccumulators();
            }),
        Action.create(Household.class, h -> h.writeOff()));
  }
}

Relative to the last version, the bank's behaviour is significantly more complicated. The bank has new methods for processing mortgage applications, processing arrears, and cumulating impairments. A final method (UpdateAccumulators) updates the bank's balance sheet data via accumulators which are reported on the console.

The Bank Class

Bank.java

import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.annotations.Variable;
import simudyne.core.functions.SerializableConsumer;

import java.util.List;

public class Bank extends Agent<MortgageModel.Globals> {

  @Variable public int debt = 0;
  @Variable public int assets = 10000000;

  @Variable
  public int equity() {
    return assets - debt;
  }

  @Variable public int nbMortgages = 0;

  public static final double NIM = 0.25;

  private int termInYears = 15; // Should be 25
  private int termInMonths = termInYears * 12;

  int impairments = 0;
  int income = 0;

  @Variable(name = "Stage 1 Provisions")
  public double stage1Provisions = 0.0;

  @Variable(name = "Stage 2 Provisions")
  public double stage2Provisions = 0.0;

  private double interest() {
    return 1 + (getGlobals().interestRate / 100);
  }

  private static Action<Bank> action(SerializableConsumer<Bank> consumer) {
    return Action.create(Bank.class, consumer);
  }

  static Action<Bank> processApplication =
      action(
          bank ->
              bank.getMessagesOfType(Messages.MortgageApplication.class).stream()
                  .filter(m -> m.amount / m.income <= bank.getGlobals().LTILimit)
                  .filter(m -> m.wealth > m.amount * (1 - bank.getGlobals().LTVLimit))
                  .forEach(
                      m -> {
                        int totalAmount =
                            (int) (m.amount * Math.pow(bank.interest(), bank.termInYears));

                        bank.send(
                                Messages.ApplicationSuccessful.class,
                                newMessage -> {
                                  newMessage.amount = totalAmount;
                                  newMessage.termInMonths = bank.termInMonths;
                                  newMessage.repayment = totalAmount / bank.termInMonths;
                                })
                            .to(m.getSender());
                        bank.nbMortgages += 1;
                        bank.assets += m.amount;
                        bank.debt += m.amount;
                      }));

  void accumulateIncome() {
    income = 0;
    getMessagesOfType(Messages.Payment.class).forEach(payment -> income += payment.repayment);

    assets += (income * NIM);
  }

  void processArrears() {
    List<Messages.Arrears> arrears = getMessagesOfType(Messages.Arrears.class);
    // Count bad loans
    calcBadLoans(arrears);
    // Calculate provisions
    double stage1Provisions = calcStageOneProvisions(arrears);
    double stage2Provisions = calcStageTwoProvisions(arrears);
    this.stage1Provisions = stage1Provisions * 0.01;
    this.stage2Provisions = stage2Provisions * 0.03;

    writeLoans(arrears);
  }

  private void calcBadLoans(List<Messages.Arrears> arrears) {
    arrears.forEach(
        arrear -> {
          if (arrear.monthsInArrears > 3) {
            getLongAccumulator("badLoans").add(1);
          }
        });
  }

  private void writeLoans(List<Messages.Arrears> arrears) {
    impairments = 0;
    arrears.forEach(
        arrear -> {
          // A mortgage is written off if it is more than 6 months in arrears.
          if (arrear.monthsInArrears > 6) {
            impairments += arrear.outstandingBalance;

            getLongAccumulator("writeOffs").add(1);

            // Notify the sender their loan was defaulted.
            send(Messages.LoanDefault.class).to(arrear.getSender());
          }
        });
    assets -= impairments;
  }

  private int calcStageTwoProvisions(List<Messages.Arrears> arrears) {
    return arrears.stream()
        .filter(m -> m.monthsInArrears > 1 && m.monthsInArrears < 3)
        .mapToInt(m -> m.outstandingBalance)
        .sum();
  }

  private int calcStageOneProvisions(List<Messages.Arrears> arrears) {
    return arrears.stream()
        .filter(m -> m.monthsInArrears <= 1)
        .mapToInt(m -> m.outstandingBalance)
        .sum();
  }

  /** Remove any mortgages that have closed from the books. */
  void clearPaidMortgages() {
    int balancePaidOff = 0;

    for (Messages.CloseMortgageAmount closeAmount :
        getMessagesOfType(Messages.CloseMortgageAmount.class)) {
      balancePaidOff += closeAmount.getBody();
      nbMortgages -= 1;
    }

    debt -= balancePaidOff;
    assets -= balancePaidOff;
  }

  public void updateAccumulators() {
    getLongAccumulator("debt").add(debt);
    getLongAccumulator("impairments").add(impairments);
    getLongAccumulator("mortgages").add(nbMortgages);
    getLongAccumulator("income").add(income);
    getLongAccumulator("assets").add(assets);
    getLongAccumulator("equity").add(equity());
  }
}

The Household Class

Similarly, the behaviour of the household has also been made more complex than in the skeleton version. The households have additional methods which capture behaviours to do with applying for and taking out a mortgage. A simple class is also included to reflect a mortgage contract.

Household.java

import simudyne.core.abm.Action;
import simudyne.core.abm.Agent;
import simudyne.core.annotations.Variable;
import simudyne.core.functions.SerializableConsumer;

public class Household extends Agent<MortgageModel.Globals> {

  @Variable public int income;
  @Variable public int wealth = 1000;

  @Variable
  public int repayment() {
    if (mortgage == null) {
      return 0;
    }

    return mortgage.repayment;
  }

  int taxBill = 0;
  Mortgage mortgage;
  int monthsInArrears = 0;

  private static Action<Household> action(SerializableConsumer<Household> consumer) {
    return Action.create(Household.class, consumer);
  }

  void earnIncome() {
    wealth += income / 12;
  }

  void payMortgage() {
    if (mortgage != null) {
      if (canPay()) {
        wealth -= mortgage.repayment;
        monthsInArrears = 0;
        mortgage.term -= 1;
        mortgage.balanceOutstanding -= mortgage.repayment;
        checkMaturity();
      } else {
        monthsInArrears += 1;
        getLinks(Links.BankLink.class)
            .send(
                Messages.Arrears.class,
                (m, l) -> {
                  m.monthsInArrears = monthsInArrears;
                  m.outstandingBalance = mortgage.balanceOutstanding;
                });
      }
    }
  }

  private void checkMaturity() {
    if (mortgage.term == 0) {
      getLinks(Links.BankLink.class).send(Messages.CloseMortgageAmount.class, mortgage.amount);
      mortgage = null;
    } else {
      getLinks(Links.BankLink.class)
          .send(
              Messages.Payment.class,
              (m, l) -> {
                m.repayment = mortgage.repayment;
                m.amount = mortgage.amount;
              });
    }
  }

  private Boolean canPay() {
    return wealth >= mortgage.repayment;
  }

  static Action<Household> applyForMortgage =
      action(
          h -> {
            if (h.mortgage == null) {
              if (h.getPrng().discrete(1, 5).sample() == 1) {
                int purchasePrice = 100000 + h.income * 2;
                h.getLinks(Links.BankLink.class)
                    .send(
                        Messages.MortgageApplication.class,
                        (m, l) -> {
                          m.amount = purchasePrice;
                          m.income = h.income;
                          m.wealth = h.wealth;
                        });
              }
            }
          });

  static Action<Household> takeOutMortgage =
      action(
          h ->
              h.hasMessageOfType(
                  Messages.ApplicationSuccessful.class,
                  message ->
                      h.mortgage =
                          new Mortgage(
                              message.amount,
                              message.amount,
                              message.termInMonths,
                              message.repayment)));

  void incomeShock() {
    // 50% of households gain volatility income, the other 50% lose it.
    if (getPrng().discrete(1, 2).sample() == 1) {
      income += (getGlobals().incomeVolatility * income / 100);
    } else {
      income -= (getGlobals().incomeVolatility * income / 100);
    }

    if (income <= 0) {
      income = 1;
    }
  }

  void payTax() {
    if (income < getGlobals().topRateThreshold) {
      taxBill = (int) ((income - getGlobals().personalAllowance) * getGlobals().basicRate / 100);
    } else {
      taxBill =
          (int)
              (((income - getGlobals().topRateThreshold) * getGlobals().topRate / 100)
                  + (income - getGlobals().personalAllowance) * getGlobals().basicRate / 100);
    }
    wealth -= taxBill / 12;
  }

  void subsistenceConsumption() {
    wealth -= 5900 / 12;

    if (wealth < 0) {
      wealth = 1;
    }
  }

  void discretionaryConsumption() {
    int incomeAfterSubsistence = income - 5900;
    double minLiqWealth =
        4.07 * Math.log(incomeAfterSubsistence) - 33.1 + getPrng().gaussian(0, 1).sample();
    double monthlyConsumption = 0.5 * Math.max(wealth - Math.exp(minLiqWealth), 0);
    wealth -= monthlyConsumption;
  }

  void writeOff() {
    if (hasMessageOfType(Messages.LoanDefault.class)) {
      mortgage = null;
    }
  }

  public static class Mortgage {
    int amount;
    int balanceOutstanding;
    int term;
    int repayment;

    public Mortgage(int amount, int balanceOutstanding, int term, int repayment) {
      this.amount = amount;
      this.balanceOutstanding = balanceOutstanding;
      this.term = term;
      this.repayment = repayment;
    }
  }
}

Messages

import simudyne.core.graph.Message;

public class Messages {
  public static class Arrears extends Message {
    int monthsInArrears;
    int outstandingBalance;
  }

  public static class LoanDefault extends Message.Empty {}

  public static class CloseMortgageAmount extends Message.Integer {}

  public static class MortgageApplication extends Message {
    int amount;
    int income;
    int wealth;
  }

  public static class ApplicationSuccessful extends Message {
    int amount;
    int termInMonths;
    int repayment;
  }

  public static class Payment extends Message {
    int repayment;
    int amount;
  }
}
import simudyne.core.graph.Link;

public class Links {
  public static class BankLink extends Link.Empty {}
}

Final Model

The final version of the model introduces additional features to ensure that it is a more accurate reflection of the system we are seeking to model.

Agents have more complex behaviour as well as additional data.

The distribution class is introduced to allow the Households to have their income drawn from data that is representative of the UK household sector as a whole. In this way, we can accurately generate any number of realistically parameterised and yet heterogeneous agents.

Multiple Runs

For many applications of simulation modelling, there is a need to run multiple runs of the same simulation. Due to the stochasticity and emergent properties of these types of model, results are best presented as a distribution.

The Simudyne SDK includes a multi-run setting which allows the user to specify a Monte Carlo evaluation of their model. We can look at this in action in the mortgage model developed in this tutorial:

mortgage model batch run

The results illustrate the range of outcomes generated by 100 simulations of the model. The grey region shows the min-max range, while the red region shows the interquartile range. The red line represents the mean of each output.

Tests

Using the Simudyne TestKit, we have created the following tests to check the behavior of the Households and Bank is as required. To read more about the TestKit and how you can use it to test the actions you created in your models, see Test Kit.

BankTest.java

import org.junit.Before;
import org.junit.Test;
import simudyne.core.abm.Action;
import simudyne.core.abm.testkit.TestKit;
import simudyne.core.abm.testkit.TestResult;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class BankTest {

  public static final double LTI_LIMIT = 4.5;
  public static final double LTV_LIMIT = 0.95;
  private TestKit<MortgageModel.Globals> testKit;
  private Bank bank;

  @Before
  public void init() {
    testKit = TestKit.create(MortgageModel.Globals.class);
    testKit.getGlobals().LTILimit = LTI_LIMIT;
    testKit.getGlobals().LTVLimit = LTV_LIMIT;
    testKit.createLongAccumulator("badLoans");
    testKit.createLongAccumulator("writeOffs");
    testKit.createLongAccumulator("debt");
    testKit.createLongAccumulator("impairments");
    testKit.createLongAccumulator("mortgages");
    testKit.createLongAccumulator("income");
    testKit.createLongAccumulator("assets");
    testKit.createLongAccumulator("equity");
    bank = testKit.addAgent(Bank.class);
  }

  @Test
  public void shouldAcceptValidMortgageApplication() {
    int initialMortgages = bank.nbMortgages;
    int initialAssets = bank.assets;
    int initialDebt = bank.debt;
    int mortgageAmount = 100;

    testKit
        .send(
            Messages.MortgageApplication.class,
            m -> {
              m.amount = mortgageAmount;
              // set an income that results in the LTI being higher than the globals LTI
              m.income = (int) ((m.amount / LTI_LIMIT) + 1);
              // set a wealth that results in the LTV being higher than the globals LTV
              m.wealth = (int) ((m.amount * (1 - LTV_LIMIT) + 1));
            })
        .to(bank);

    TestResult testResult = testKit.testAction(bank, Bank.processApplication);
    assertEquals(1, testResult.getMessagesOfType(Messages.ApplicationSuccessful.class).size());

    assertEquals(initialMortgages + 1, bank.nbMortgages);
    assertEquals(initialAssets + mortgageAmount, bank.assets);
    assertEquals(initialDebt + mortgageAmount, bank.debt);
  }

  @Test
  public void shouldRejectMortgageApplicationInvalidLTI() {
    testKit
        .send(
            Messages.MortgageApplication.class,
            m -> {
              m.amount = 100;
              // set an income that results in the LTI being lower than the globals LTI
              m.income = (int) ((m.amount / LTI_LIMIT) - 1);
            })
        .to(bank);

    TestResult testResult = testKit.testAction(bank, Bank.processApplication);
    assertTrue(testResult.getMessagesOfType(Messages.ApplicationSuccessful.class).isEmpty());
  }

  @Test
  public void shouldRejectMortgageApplicationInvalidLTV() {
    testKit
        .send(
            Messages.MortgageApplication.class,
            m -> {
              m.amount = 100;
              m.income = 100;
              // set a wealth that results in the LTV being lower than the globals LTV
              m.wealth = (int) ((m.amount * (1 - LTV_LIMIT) - 1));
            })
        .to(bank);

    TestResult testResult = testKit.testAction(bank, Bank.processApplication);
    assertTrue(testResult.getMessagesOfType(Messages.ApplicationSuccessful.class).isEmpty());
  }

  @Test
  public void shouldAccumulateIncome() {
    int initialAssets = bank.assets;
    int repayment1 = 103;
    int repayment2 = 108;
    testKit.send(Messages.Payment.class, p -> p.repayment = repayment1).to(bank);
    testKit.send(Messages.Payment.class, p -> p.repayment = repayment2).to(bank);

    testKit.testAction(bank, Action.create(Bank.class, Bank::accumulateIncome));

    assertEquals(initialAssets + ((repayment1 + repayment2) * bank.NIM), bank.assets, 1);
  }

  @Test
  public void shouldProcessStage1Arrears() {
    int initialAssets = bank.assets;
    int outstandingBalance1 = 34;
    int outstandingBalance2 = 56;
    sendArrearsMessage(outstandingBalance1, 1);
    sendArrearsMessage(outstandingBalance2, 1);

    TestResult testResult =
        testKit.testAction(bank, Action.create(Bank.class, Bank::processArrears));

    assertEquals(0, testResult.getLongAccumulator("badLoans").value());
    assertEquals(0, testResult.getLongAccumulator("writeOffs").value());
    assertEquals(0, bank.stage2Provisions, 0);
    assertEquals((outstandingBalance1 + outstandingBalance2) * 0.01, bank.stage1Provisions, 0);
    assertEquals(0, testResult.getMessagesOfType(Messages.LoanDefault.class).size());
    assertEquals(initialAssets, bank.assets);
  }

  @Test
  public void shouldProcessStage2Arrears() {
    int initialAssets = bank.assets;
    int outstandingBalance1 = 34;
    int outstandingBalance2 = 56;
    sendArrearsMessage(outstandingBalance1, 2);
    sendArrearsMessage(outstandingBalance2, 2);

    TestResult testResult =
        testKit.testAction(bank, Action.create(Bank.class, Bank::processArrears));

    assertEquals(0, testResult.getLongAccumulator("badLoans").value());
    assertEquals(0, testResult.getLongAccumulator("writeOffs").value());
    assertEquals(0, bank.stage1Provisions, 0);
    assertEquals((outstandingBalance1 + outstandingBalance2) * 0.03, bank.stage2Provisions, 0);
    assertEquals(0, testResult.getMessagesOfType(Messages.LoanDefault.class).size());
    assertEquals(initialAssets, bank.assets);
  }

  @Test
  public void shouldProcessBadLoans() {
    int initialAssets = bank.assets;
    int outstandingBalance1 = 34;
    int outstandingBalance2 = 56;
    sendArrearsMessage(outstandingBalance1, 4);
    sendArrearsMessage(outstandingBalance2, 4);

    TestResult testResult =
        testKit.testAction(bank, Action.create(Bank.class, Bank::processArrears));

    assertEquals(2, testResult.getLongAccumulator("badLoans").value());
    assertEquals(0, testResult.getLongAccumulator("writeOffs").value());
    assertEquals(0, bank.stage1Provisions, 0);
    assertEquals(0, bank.stage2Provisions, 0);
    assertEquals(0, testResult.getMessagesOfType(Messages.LoanDefault.class).size());
    assertEquals(initialAssets, bank.assets);
  }

  @Test
  public void shouldProcessWriteOffs() {
    int initialAssets = bank.assets;
    int outstandingBalance1 = 34;
    int outstandingBalance2 = 56;
    sendArrearsMessage(outstandingBalance1, 7);
    sendArrearsMessage(outstandingBalance2, 7);

    TestResult testResult =
        testKit.testAction(bank, Action.create(Bank.class, Bank::processArrears));

    assertEquals(2, testResult.getLongAccumulator("badLoans").value());
    assertEquals(2, testResult.getLongAccumulator("writeOffs").value());
    assertEquals(0, bank.stage1Provisions, 0);
    assertEquals(0, bank.stage2Provisions, 0);
    assertEquals(2, testResult.getMessagesOfType(Messages.LoanDefault.class).size());
    assertEquals(initialAssets - outstandingBalance1 - outstandingBalance2, bank.assets);
  }

  private void sendArrearsMessage(int outstandingBalance, int monthsInArrears) {
    testKit
        .send(
            Messages.Arrears.class,
            m -> {
              m.monthsInArrears = monthsInArrears;
              m.outstandingBalance = outstandingBalance;
            })
        .to(bank);
  }

  @Test
  public void shouldProcessPaidMortgages() {
    int initialDebt = bank.debt;
    int initialAssets = bank.assets;
    int initialNbMortgages = bank.nbMortgages;

    int mortgageAmount1 = 59;
    int mortgageAmount2 = 32;
    testKit
        .send(Messages.CloseMortgageAmount.class, amount -> amount.setBody(mortgageAmount1))
        .to(bank);
    testKit
        .send(Messages.CloseMortgageAmount.class, amount -> amount.setBody(mortgageAmount2))
        .to(bank);

    testKit.testAction(bank, Action.create(Bank.class, Bank::clearPaidMortgages));

    assertEquals(initialDebt - mortgageAmount1 - mortgageAmount2, bank.debt);
    assertEquals(initialAssets - mortgageAmount1 - mortgageAmount2, bank.assets);
    assertEquals(initialNbMortgages - 2, bank.nbMortgages);
  }

  @Test
  public void shouldUpdateAccumulators() {
    bank.debt = 98;
    bank.impairments = 23;
    bank.income = 90;
    bank.assets = 98765;
    bank.nbMortgages = 8;

    TestResult testResult =
        testKit.testAction(bank, Action.create(Bank.class, Bank::updateAccumulators));

    assertEquals(bank.debt, testResult.getLongAccumulator("debt").value());
    assertEquals(bank.impairments, testResult.getLongAccumulator("impairments").value());
    assertEquals(bank.nbMortgages, testResult.getLongAccumulator("mortgages").value());
    assertEquals(bank.income, testResult.getLongAccumulator("income").value());
    assertEquals(bank.assets, testResult.getLongAccumulator("assets").value());
    assertEquals(bank.assets - bank.debt, testResult.getLongAccumulator("equity").value());
  }
}

HouseholdTest.java

import org.junit.Before;
import org.junit.Test;
import simudyne.core.abm.Action;
import simudyne.core.abm.testkit.TestKit;
import simudyne.core.abm.testkit.TestResult;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;

public class HouseholdTest {

  private static final int TARGET_BANK_LINK_ID = 23;
  private TestKit<MortgageModel.Globals> testKit;
  private Household household;
  private Household.Mortgage dummyMortgage = new Household.Mortgage(1, 1, 1, 1);

  @Before
  public void init() {
    testKit = TestKit.create(MortgageModel.Globals.class);
    household = testKit.addAgent(Household.class);
    testKit.registerLinkTypes(Links.BankLink.class);
    household.addLink(TARGET_BANK_LINK_ID, Links.BankLink.class);
  }

  @Test
  public void applyForMortgageDoesNothingWhenMortgageExists() {
    household.mortgage = dummyMortgage;

    TestResult result = testKit.testAction(household, Household.applyForMortgage);

    assertFalse(result.getMessageIterator().hasNext());
  }

  @Test
  public void takeOutMortgageSavesMortgageLocallyOnApplicationSuccessfulMessage() {
    int mortgageAmount = 23;
    int mortgageTerm = 34;
    int mortgageRepayment = 1;
    testKit
        .send(
            Messages.ApplicationSuccessful.class,
            m -> {
              m.amount = mortgageAmount;
              m.termInMonths = mortgageTerm;
              m.repayment = mortgageRepayment;
            })
        .to(household);

    testKit.testAction(household, Household.takeOutMortgage);
    Household.Mortgage expectedMortgage =
        new Household.Mortgage(mortgageAmount, mortgageAmount, mortgageTerm, mortgageRepayment);
    assertEquals(expectedMortgage, household.mortgage);
  }

  @Test
  public void takeOutMortgageDoesNothingWhenNoMessages() {
    testKit.testAction(household, Household.takeOutMortgage);
    assertNull(household.mortgage);
  }

  @Test
  public void writeOff_setsMortgageToNull() {
    household.mortgage = dummyMortgage;
    testKit.send(Messages.LoanDefault.class).to(household);

    testKit.testAction(household, Action.create(Household.class, Household::writeOff));

    assertNull(household.mortgage);
  }

  @Test
  public void writeOfDoesNothingWhenNoMessages() {
    household.mortgage = dummyMortgage;
    testKit.testAction(household, Action.create(Household.class, Household::writeOff));

    assertEquals(dummyMortgage, household.mortgage);
  }

  @Test
  public void payMortgageWhenMortgageMatured() {
    int mortgageAmount = 34;
    int balanceOutstanding = 12;
    int mortgageRepayment = 12;
    int initialWealth = 100;
    household.mortgage =
        new Household.Mortgage(mortgageAmount, balanceOutstanding, 1, mortgageRepayment);
    household.wealth = initialWealth;

    TestResult testResult =
        testKit.testAction(household, Action.create(Household.class, Household::payMortgage));

    assertEquals(initialWealth - mortgageRepayment, household.wealth);
    assertEquals(0, household.monthsInArrears);
    assertNull(household.mortgage);

    Messages.CloseMortgageAmount messageResult =
        testResult.getMessagesOfType(Messages.CloseMortgageAmount.class).get(0);
    assertEquals(mortgageAmount, messageResult.getBody());
  }

  @Test
  public void payMortgageWhenMortgageNOTMatured() {
    int mortgageAmount = 34;
    int balanceOutstanding = 24;
    int mortgageRepayment = 12;
    int initialWealth = 100;
    household.mortgage =
        new Household.Mortgage(mortgageAmount, balanceOutstanding, 2, mortgageRepayment);
    household.wealth = initialWealth;

    TestResult testResult =
        testKit.testAction(household, Action.create(Household.class, Household::payMortgage));

    assertEquals(initialWealth - mortgageRepayment, household.wealth);
    assertEquals(0, household.monthsInArrears);
    assertEquals(1, household.mortgage.term);
    assertEquals(balanceOutstanding - mortgageRepayment, household.mortgage.balanceOutstanding);

    Messages.Payment messageResult = testResult.getMessagesOfType(Messages.Payment.class).get(0);
    assertEquals(mortgageAmount, messageResult.amount);
    assertEquals(mortgageRepayment, messageResult.repayment);
  }

  @Test
  public void payMortgageWhenInArrears() {
    int mortgageAmount = 34;
    int balanceOutstanding = 24;
    int mortgageRepayment = 12;
    int initialWealth = 1;
    household.mortgage =
        new Household.Mortgage(mortgageAmount, balanceOutstanding, 2, mortgageRepayment);
    household.wealth = initialWealth;

    TestResult testResult =
        testKit.testAction(household, Action.create(Household.class, Household::payMortgage));

    Messages.Arrears messageResult = testResult.getMessagesOfType(Messages.Arrears.class).get(0);
    assertEquals(household.monthsInArrears, messageResult.monthsInArrears);
    assertEquals(household.mortgage.balanceOutstanding, messageResult.outstandingBalance);
  }
}