Composing Contracts, Part 2: Observables, and the Two Kinds of Choice

scaladsl

Following “Composing Contracts” by Peyton Jones, Eber and Seward (ICFP 2000). Part 1 gave the ten primitives and built bonds and European options out of them. Two things were deferred: what an observable is, and what the two choice combinators are for.

This post does both, and the second half is the most instructive stretch of the paper — a derivation that goes wrong twice before it goes right, with the wrongness explained each time. That’s rare in a published paper and it’s the bit I’d keep if I could keep only one.

🔗Contracts mention things that vary

Real contracts refer to quantities measured later. “Pay an amount in sterling equal to the three-month LIBOR spot rate multiplied by 100.” Or, in the paper’s deliberately silly example, “receive an amount in dollars equal to the noon Centigrade temperature in Los Angeles.”

The authors call these observables, and their definition is precise in a way worth copying: an observable is a quantity that is objective — at any given time, both parties to the contract will agree on its value. The temperature in Los Angeles is objective and can be measured. The value to me of insuring my house is not; it’s subjective, and it is therefore not an observable and cannot appear in a contract.

That distinction is doing real work. It’s the line between “this contract can be settled without a lawyer” and “this contract cannot.”

So observables get their own type, distinct from contracts:

type Obs[A]   // a time-varying quantity of type A

val noonTempInLA: Obs[Double]
val libor3m: Obs[Double]

And the scale primitive from part 1 takes one:

case Scale(o: Obs[Double], c: Contract)

with a semantics the paper is careful to nail down: acquiring Scale(o, c) acquires c at the same moment, with every payment multiplied by o sampled at the moment of acquisition — one well-defined instant, one number, applied to everything that follows.

Being that specific matters. “Scale by the temperature” is ambiguous — the temperature when? Fixing it to the acquisition date is a design decision, and part 3 shows it was chosen precisely because it makes the valuation rule a one-liner.

🔗The observable combinators

enum Obs[A]:
  case Konst(a: A)
  case TimeObs(t: Date)                              extends Obs[Days]
  case Lift[A, B](f: A => B, o: Obs[A])              extends Obs[B]
  case Lift2[A, B, C](f: (A, B) => C,
                      o1: Obs[A], o2: Obs[B])        extends Obs[C]

Konst(x) has value x at every time. TimeObs(t) has, at time s, the number of days between s and t. Lift and Lift2 apply a function pointwise.

That’s a GADTLift introduces a type A that doesn’t appear in the result, so it’s existential, and Scala 3 needs the explicit extends Obs[B] form to express it.

The paper notes that observables are strongly reminiscent of the behaviours in Fran, Conal Elliott and Paul Hudak’s reactive animation library. That’s the same Signal a = Time -> a that turns up in the MUI chapter of HSoM — a time-varying value with lifted arithmetic. Three domains, one abstraction, and everybody arrives at lift, lift2 and a Num instance.

🔗Where Scala’s standard library lets us down

In Haskell the paper writes one line:

instance Num a => Num (Obs a)

and from then on noonTempInLA + konst 373 just works. Haskell’s Num class asks for exactly +, -, *, negate, abs, signum and fromInteger. Every one of those makes sense pointwise on a time-varying quantity.

The obvious Scala translation is Numeric[Obs[Double]]. It doesn’t work, and the reason is structural rather than fiddly.

scala.math.Numeric[T] extends Ordering[T]. So implementing it obliges you to supply:

def compare(x: Obs[Double], y: Obs[Double]): Int
def toInt(x: Obs[Double]): Int
def toDouble(x: Obs[Double]): Double

None of those exist. You cannot compare two symbolic, time-varying quantities without picking a time — is libor3m greater than noonTempInLA? The question has no answer until you say when, and even then it’s a random variable rather than a number. toDouble is worse: it asks you to collapse an entire process to one Double.

You can of course write something that typechecks by throwing. Then Numeric’s laws are violated, anything generic over Numeric is a landmine, and you’ve bought syntax with a lie.

Two honest options:

Extension methods, which is what I’d ship:

extension (o: Obs[Double])
  def +(p: Obs[Double]): Obs[Double] = Lift2[Double, Double, Double](_ + _, o, p)
  def -(p: Obs[Double]): Obs[Double] = Lift2[Double, Double, Double](_ - _, o, p)
  def *(p: Obs[Double]): Obs[Double] = Lift2[Double, Double, Double](_ * _, o, p)
  def unary_- : Obs[Double]          = Lift[Double, Double](-_, o)

given Conversion[Double, Obs[Double]] = Konst(_)

The Conversion lets you write libor3m * 100 with a bare literal. Scala 3 requires import scala.language.implicitConversions to define it, which is the compiler asking whether you’re sure — and here, in a narrow DSL where the coercion is total and obvious, I am.

Or spire’s algebra, which is the closer analogue of Haskell’s Num because it isn’t tangled up with ordering:

given Ring[Obs[Double]] with
  def plus(x: Obs[Double], y: Obs[Double]) = ...
  def times(x: Obs[Double], y: Obs[Double]) = ...
  def negate(x: Obs[Double]) = ...
  def zero = Konst(0.0)
  def one  = Konst(1.0)

Ring asks for exactly the operations that make sense and nothing else. That’s the lawful answer, and it’s a small illustration of a general point: Scala’s standard numeric hierarchy bundles arithmetic with ordering and conversion, so it only fits types that are genuinely numbers. Haskell’s is finer-grained, and for a symbolic DSL that granularity is the difference between a one-line instance and a lie. If you want the fine-grained version in Scala you go outside the standard library.

With either, scaleK from part 1 is what it always should have been:

def scaleK(x: Double, c: Contract): Contract = Scale(Konst(x), c)

🔗Two kinds of choice

Now the part the paper describes as a breakthrough.

Almost all the subtlety in financial contracts comes from participants being able to make choices. The insight is that there are exactly two kinds, and they should be two combinators, each capturing its kind and nothing else:

  • Or(c1, c2) — choose which. Acquire exactly one of two contracts, immediately.
  • Anytime(c) — choose when. You must acquire c, but you pick the moment, any time up to c’s horizon.

Separating those is not obvious. The financial catalogue mixes them freely — a “Bermudan swaption” bundles both kinds of choice with a payment schedule and a set of exercise dates — and it took the authors, in their words, an extended iterative process to boil the soup of traded contracts down to a small set of combinators.

The test of whether the separation was right is whether the compound products fall out. So:

🔗Deriving an American option, wrongly, twice

A European option we did in part 1: the right, on one specific date, to take an underlying contract or not.

def perhaps(t: Date, u: Contract): Contract  = Truncate(t, u or Zero)
def european(t: Date, u: Contract): Contract = Get(perhaps(t, u))

An American option is more flexible: the right to acquire an underlying contract at any time between two dates, or not at all. So we need both kinds of choice at once, and this is where it gets interesting.

Attempt one.

def american(t1: Date, t2: Date, u: Contract): Contract =
  Anytime(perhaps(t2, u))                              // wrong

perhaps(t2, u) is “take u or nothing, decide by t2”. Anytime lets us pick the moment. Looks right, and it’s wrong for a reason you can spot by inspection: t1 never appears. Whatever this contract is, it isn’t one whose behaviour depends on t1, and the American option’s does.

Attempt two.

def american(t1: Date, t2: Date, u: Contract): Contract =
  Get(Truncate(t1, Anytime(perhaps(t2, u))))           // also wrong

Now t1 is in there. Get(Truncate(t1, _)) means “acquire the inner thing at t1”, which correctly captures that acquiring the option early is no better than acquiring it at t1.

But it’s wrong in the other direction: it makes t1 the only acquisition date. You can no longer acquire the option after t1, and you should be able to — an American option acquired midway through its exercise window is a perfectly ordinary thing to own.

What we actually want to say is: before t1 you get this; after t1 you get that. Which is precisely what the thereafter combinator is for, and this is where it earns its place in the ten:

def american(t1: Date, t2: Date, u: Contract): Contract =
  val opt = Anytime(perhaps(t2, u))
  Get(Truncate(t1, opt)) thereafter opt

Before t1: Get(Truncate(t1, opt)) — you hold the option, and it starts at t1. After t1: the bare opt — you hold an option you can exercise any time up to t2.

Three primitives combining two kinds of choice with a time split, and no new machinery.

I want to dwell on the shape of that derivation, because it’s the thing worth stealing. Both wrong answers were wrong in a way you could name precisely — “t1 is unused”, “this forbids acquisition after t1” — and each diagnosis pointed at the fix. That only works because every combinator has a single, sharply-specified meaning. With a fat americanOption primitive taking five arguments, “wrong” would have meant a mispriced trade found in production six months later.

Note also that opt appears twice. The paper flags this in passing and returns to it in its final section as an open problem: a naive evaluator will do all the work of valuing opt twice, with no way to notice that it’s the same contract. That’s the memoisation problem, and it’s part 4.

🔗Where we are

Ten primitives, two kinds of choice, a second type for time-varying quantities, and a derived vocabulary covering bonds, European options and American options — each a one-liner, none of them requiring anything new.

The paper is candid that the combinators aren’t yet enough for everything actively traded, and that they were extending the set. But the two conclusions it draws hold: financial contracts can be described purely declaratively, and a huge variety of them reduce to a small number of combinators.

We can now describe contracts precisely. We still can’t answer the only question anyone actually asks about one — what is it worth? That needs a compositional semantics mapping every combinator to a mathematical object, and it’s where the design decisions of parts 1 and 2 either pay off or don’t.

They pay off. The entire semantics is ten equations.

Next: Part 3 — What is a contract worth?


🔗Sources

  • Peyton Jones, Simon, Jean-Marc Eber & Julian Seward. Composing Contracts: An Adventure in Financial Engineering. ICFP 2000. The observable concept and its objectivity criterion, the konst/lift/lift2 combinators, the separation of or and anytime as the two forms of choice, and the three-stage derivation of american — including both wrong attempts and the reasons they fail — are all theirs.
  • Elliott, Conal & Paul Hudak. Functional Reactive Animation. ICFP 1997 — Fran’s behaviours, which the paper notes observables resemble.
  • spire (Typelevel) for Ring, the fine-grained algebra Scala’s standard library lacks.

The Scala 3 translation, and the analysis of why scala.math.Numeric cannot lawfully hold an observable, are mine.