How Scheduling Works
This page explains how FinanceSim decides when to run each model during a simulation. Understanding scheduling is important when creating new models because it determines how often your model's logic executes.
The Big Picture
Imagine you're building a financial simulation with several models:
- A job that pays you twice a month
- A savings account that calculates interest monthly
- A bill that charges you every 30 days
Each of these models needs to run at different times. The scheduler is the part of FinanceSim that keeps track of when each model should run and calls them at the right times.
Key Concepts
Simulation Time
FinanceSim measures time in days. When you run a simulation for 365 days, that represents one year. Time starts at 0 and increases as the simulation progresses.
The simulation does NOT run in real-time. It jumps from one scheduled event to the next, which makes simulations run very fast.
The Schedule Struct
Every model has a Schedule that tells the scheduler when to call it. A Schedule has four fields:
| Field | Type | Description | Default |
start_time | double | Day when the model starts executing | 0.0 |
stop_time | double | Day when the model stops (-1 means never) | -1.0 |
rate | double | How many days between executions | 1.0 |
timing | enum | When in the period to execute | StartOfPeriod |
Here's what each field means in plain English:
- start_time: "Don't run me until day X"
- stop_time: "Stop running me after day Y" (or -1 for "run forever")
- rate: "Run me every N days"
- timing: "Run at the start or end of my period"
Example Schedules
Daily execution (the default):
Schedule daily;
daily.rate = 1.0;
Semi-monthly (like a paycheck):
Schedule semiMonthly;
semiMonthly.rate = 365.0 / 24.0;
Monthly:
Schedule monthly;
monthly.rate = 30.0;
Event-driven only (no scheduled updates):
Schedule eventOnly;
eventOnly.rate = 0.0;
Starts after 90 days:
Schedule delayed;
delayed.start_time = 90.0;
delayed.rate = 7.0;
Temporary (runs for one year only):
Schedule temporary;
temporary.start_time = 0.0;
temporary.stop_time = 365.0;
temporary.rate = 30.0;
How the Scheduler Works
The scheduler maintains a priority queue of upcoming model executions, sorted by time. Here's the process:
- Registration: When you add a model to the simulation, the scheduler calculates when it should first run based on its Schedule.
- Running: The scheduler repeatedly:
- Looks at the next scheduled execution
- Advances simulation time to that moment
- Calls the model's
update(time) method
- Schedules the model's next execution (if applicable)
- Jumping: Time doesn't tick second-by-second. It jumps directly to the next scheduled event. If nothing is scheduled between day 10 and day 25, time jumps from 10 to 25 instantly.
Visual Example
Imagine three models:
- Model A: runs daily (rate = 1)
- Model B: runs every 3 days (rate = 3)
- Model C: runs weekly (rate = 7)
The execution order would be:
Day 0: A runs, B runs, C runs
Day 1: A runs
Day 2: A runs
Day 3: A runs, B runs
Day 4: A runs
Day 5: A runs
Day 6: A runs, B runs
Day 7: A runs, C runs
...
Event-Driven vs Scheduled Models
FinanceSim supports two types of model behavior, and understanding when to use each is crucial for building effective simulations.
Scheduled Models (rate > 0)
These models run automatically at regular intervals. Examples:
- A job that pays every 15 days
- A savings account calculating interest monthly
The scheduler calls their update() method according to their schedule. You implement your logic in update() and it gets called automatically.
Event-Driven Models (rate = 0 or rate <= 0)
These models only run when something happens. Examples:
- A checking account that responds to income/expense events
- A notification system that triggers on certain conditions
When rate <= 0, the scheduler never automatically calls update(). Instead, the model responds to events it subscribes to via the EventBus.
How Event-Driven Updates Work
Event-driven models use the publish/subscribe pattern. Here's the flow:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Model A │ ──────> │ EventBus │ ──────> │ Model B │
│ (publisher) │ publish │ │ deliver │ (subscriber)│
│ │ event │ │ event │ │
└─────────────┘ └─────────────┘ └─────────────┘
- Model A publishes an event (e.g.,
IncomeEvent)
- EventBus receives the event and looks up all subscribers
- Model B (which subscribed to
IncomeEvent) has its callback invoked immediately
Setting Up Event Subscriptions
You subscribe to events in your model's initialize() method:
void MyAccount::initialize(EventBus& bus) {
bus_ = &bus;
income_subscription_ = bus.subscribe<IncomeEvent>(
[this](const IncomeEvent& event) {
on_income_received(event);
}
);
expense_subscription_ = bus.subscribe<ExpenseEvent>(
[this](const ExpenseEvent& event) {
on_expense_occurred(event);
}
);
}
Key points about subscriptions:
- Immediate execution: When an event is published, your callback runs right away, before the publishing model's
update() even returns
- Save the subscription ID: You need it to unsubscribe later
- Capture
this: The lambda captures this so you can access your model's member variables
- Const reference: Events are passed as
const& - you cannot modify them
Handling Events
Your event handler can do anything: update internal state, publish new events, or just record information:
void MyAccount::on_income_received(const IncomeEvent& event) {
if (event.target_account() != routing_tag_ &&
!(event.target_account().empty() && routing_tag_ == "default")) {
return;
}
balance_ += event.amount();
emit<AccountEvent>(event.timestamp(), id_, balance_, event.amount(), "deposit");
}
Cleaning Up Subscriptions
Always unsubscribe in reset() to prevent dangling callbacks:
void MyAccount::reset() {
if (bus_) {
bus_->unsubscribe(income_subscription_);
bus_->unsubscribe(expense_subscription_);
}
bus_ = nullptr;
income_subscription_ = 0;
expense_subscription_ = 0;
}
Why this matters: If you don't unsubscribe and the simulation resets, the old callbacks still exist and point to potentially invalid memory.
Hybrid Models: Both Scheduled AND Event-Driven
A model can be BOTH scheduled AND event-driven. This is common and powerful. For example, a savings account might:
- Respond immediately to deposit events (event-driven)
- Also calculate interest monthly (scheduled with rate = 30)
SavingsAccount::SavingsAccount(...)
: AccountBase(..., Schedule{0.0, -1.0, 30.0, ...})
{
}
void SavingsAccount::initialize(EventBus& bus) {
AccountBase::initialize(bus);
}
void SavingsAccount::on_income(const IncomeEvent& event) {
accrue_interest_up_to(event.timestamp());
deposit(event.timestamp(), event.amount(), event.category());
}
void SavingsAccount::update(SimTime time) {
accrue_interest_up_to(time);
}
This hybrid approach lets the savings account:
- React instantly to deposits (no waiting for the next scheduled update)
- Ensure interest is calculated even if no deposits happen that month
When to Use Each Approach
| Use Case | Approach | Why |
| Regular payments (salary, rent) | Scheduled | Predictable timing, no trigger needed |
| Responding to transactions | Event-driven | Need immediate reaction |
| Interest calculation | Hybrid | Regular calculation + catch-up on transactions |
| Alerts/notifications | Event-driven | Only act when something happens |
| Daily market prices | Scheduled | Updates regardless of other activity |
Configuring Schedules in Your Models
In the Constructor
Most models set their schedule in the constructor by passing it to their base class:
CareerJob::CareerJob(std::string id, std::string name, double salary, SimTime start_day)
: IncomeBase(std::move(id), std::move(name),
Schedule{start_day, -1.0, 15.2, ExecutionTiming::EndOfPeriod})
{
}
Accepting a Custom Schedule
For flexibility, you can accept a Schedule as a constructor parameter:
SavingsAccount::SavingsAccount(std::string id, std::string name,
double apy, double initial_balance,
std::string routing_tag,
Schedule schedule)
: AccountBase(std::move(id), std::move(name),
std::move(routing_tag), initial_balance,
schedule)
{
}
This lets users override the default:
savings = SavingsAccount("s1", "My Savings", apy=0.045)
daily_schedule = Schedule()
daily_schedule.rate = 1.0
savings = SavingsAccount("s1", "My Savings", apy=0.045,
schedule=daily_schedule)
ExecutionTiming
The timing field controls whether the model runs at the start or end of its period. This matters when multiple models interact:
- StartOfPeriod: Run at the beginning of the interval (default)
- EndOfPeriod: Run at the end of the interval
For example, a paycheck model might use EndOfPeriod to represent receiving payment at the end of a pay period rather than the start.
Schedule payday;
payday.rate = 15.0;
payday.timing = ExecutionTiming::EndOfPeriod;
Common Patterns
"Run Once at Startup"
To run exactly once when the simulation starts:
Schedule runOnce;
runOnce.start_time = 0.0;
runOnce.stop_time = 0.0;
runOnce.rate = 1.0;
"Wait for Event, Then Start"
Some models need to remain dormant until something triggers them. For example:
- A loan repayment model that starts only after a
LoanOriginationEvent
- An investment model that activates when funds are transferred in
- A subscription service that begins after a
SignupEvent
There are several ways to implement this pattern, each with trade-offs.
Approach 1: Use <tt>schedule_at()</tt> for One-Time Triggers
The scheduler's schedule_at() method lets you schedule a model to run at a specific future time. This is useful when an event should trigger a single future update:
def on_signup(event):
scheduler.schedule_at(billing_model, event.timestamp + 30.0)
void MyModel::on_trigger_event(const TriggerEvent& event) {
first_payment_time_ = event.timestamp() + 30.0;
}
Limitation: schedule_at() schedules a single execution. If you need recurring updates after the trigger, you'll need a different approach.
Approach 2: Start with Event-Driven, Then Switch to Scheduled
A more flexible pattern is to start as event-driven (rate = 0) and dynamically enable scheduling when triggered. However, FinanceSim's current Schedule is set at construction time. The workaround is to track state internally:
class DelayedStartModel : public Model {
public:
DelayedStartModel(std::string id)
: id_(std::move(id))
{
schedule_.rate = 30.0;
schedule_.start_time = 0.0;
}
void initialize(EventBus& bus) override {
bus_ = &bus;
sub_id_ = bus.subscribe<ActivationEvent>(
[this](const ActivationEvent& event) {
activated_ = true;
activation_time_ = event.timestamp();
}
);
}
void update(SimTime time) override {
if (!activated_) {
return;
}
do_monthly_payment(time);
}
private:
bool activated_ = false;
};
double SimTime
Represents a point in simulation time (continuous, in days)
Pros: Simple to understand, model is always in the schedule Cons: Model's update() is called even when inactive (minor performance cost)
Approach 3: Purely Event-Driven with Internal Scheduling
For complex scenarios, you can manage your own timing entirely through events:
class EventTriggeredRecurring : public Model {
public:
EventTriggeredRecurring(std::string id, SimTime interval)
: id_(std::move(id)), interval_(interval)
{
schedule_.rate = 0.0;
}
void initialize(EventBus& bus) override {
bus_ = &bus;
activation_sub_ = bus.subscribe<StartEvent>(
[this](const StartEvent& event) {
if (!active_) {
active_ = true;
next_execution_ = event.timestamp() + interval_;
}
}
);
time_sub_ = bus.subscribe<TimeTickEvent>(
[this](const TimeTickEvent& event) {
if (active_ && event.timestamp() >= next_execution_) {
do_work(event.timestamp());
next_execution_ += interval_;
}
}
);
}
private:
bool active_ = false;
};
Pros: Complete control over timing, truly dormant until activated Cons: More complex, requires a time-tick mechanism
Approach 4: Use <tt>start_time</tt> for Known Activation Times
If you know when the model should activate at construction time, simply set start_time:
Schedule loan_schedule;
loan_schedule.start_time = 30.0;
loan_schedule.rate = 30.0;
This is the simplest approach when the activation time is predetermined.
Choosing the Right Approach
| Scenario | Recommended Approach |
| Activation time known at construction | Set start_time |
| One-time future action after event | schedule_at() |
| Recurring actions after unknown trigger | Approach 2 (conditional update) |
| Complex timing logic | Approach 3 (internal scheduling) |
Example: Loan That Starts After Funding
Here's a complete example of a loan model that remains dormant until it receives a funding event:
class Loan : public Model {
public:
Loan(std::string id, double principal, double rate, int term_months)
: id_(std::move(id))
, principal_(principal)
, interest_rate_(rate)
, term_months_(term_months)
{
schedule_.rate = 30.0;
monthly_payment_ = calculate_payment(principal, rate, term_months);
}
void initialize(EventBus& bus) override {
bus_ = &bus;
funding_sub_ = bus.subscribe<LoanFundedEvent>(
[this](const LoanFundedEvent& event) {
if (event.loan_id() == id_ && !funded_) {
funded_ = true;
balance_ = principal_;
first_payment_due_ = event.timestamp() + 30.0;
}
}
);
}
void update(SimTime time) override {
if (!funded_) {
return;
}
if (time < first_payment_due_) {
return;
}
if (balance_ <= 0) {
return;
}
make_payment(time);
}
private:
bool funded_ = false;
double balance_ = 0.0;
};
This pattern keeps the model simple while handling the "wait for event" requirement cleanly.
"Run During Specific Period"
To model something like a 2-year contract:
Schedule contract;
contract.start_time = 30.0;
contract.stop_time = 760.0;
contract.rate = 30.0;
Summary
- Schedule controls when a model's
update() is called
- rate is the interval in days (0 = event-driven only)
- start_time and stop_time bound when the model is active
- The scheduler jumps between events (no fixed time step)
- Models can be scheduled, event-driven, or both
For a complete guide on creating new models that use scheduling, see Adding a New Model.