The factor model's fair value¶
The factor model's fair value, as a ratio to the instrument's reference price, V / V0 = exp(sum_j L_j f_j + e): the single-index and multi-factor models, prices co-moving through shared factors.
Over a game's whole table of loadings: each loading's instrument (the item it belongs to), its weight L_j and its factor, a relation to whatever table the game's column relates to, whose level f_j (a log-return from its start) it reads; e is the instrument's own idiosyncratic term. It folds the loadings of the item's own instrument, in the fixed tree. A game multiplies its own reference price by the ratio, in its own currency: the library's finance set carries none. Source: Sharpe, A Simplified Model for Portfolio Analysis (1963); Elton, Gruber, Brown & Goetzmann, Modern Portfolio Theory and Investment Analysis, ch. 7-8. Checked against a reference in numpy, which has its own tests.
- Source
- Sharpe, A Simplified Model for Portfolio Analysis (1963); Elton, Gruber et al., Modern Portfolio Theory, ch. 7-8.
- Proof
- invariant, limit.
fair_value.trait
namespace lusine.finance;
float fair_value(list<struct { relation instrument; float loading; relation factor; }> loadings, float idiosyncratic)
{
return exp((sum(loading * factor.level) where instrument == it) + idiosyncratic);
}
An instrument's fair value as a ratio to its reference price, V / V0 = exp(Σ_j L_j f_j + e), prices co-moving through shared factors. The kernel takes a game's whole table of loadings, list<struct { relation instrument; float loading; relation factor; }>: each loading's instrument, its weight and its factor, a relation to whatever table holds the factor's level. A game multiplies its own reference price by the ratio, in its own currency.
import os, sys
root = os.getcwd()
while not os.path.isfile(os.path.join(root, 'docsite.json')):
root = os.path.dirname(root)
sys.path.insert(0, os.path.join(root, 'tools'))
sys.path.insert(0, os.path.join(root, 'kernels'))
import numpy as np
import matplotlib.pyplot as plt
from lusine import trait_run
F32 = np.float32
U = float(np.finfo(F32).eps) / 2 # binary32's unit roundoff, 2^-24
The kernel¶
The law as the library states it.
print(trait_run.source('finance/fair_value.trait'))
// lusine.finance.fair_value: the factor model's fair value, as a ratio to the instrument's reference price, V / V0 =
// exp(sum_j L_j f_j + e): the single-index and multi-factor models, prices co-moving through shared factors. Over a
// game's whole table of loadings: each loading's instrument (the item it belongs to), its weight L_j
// and its factor, a relation to whatever table the game's column relates to, whose level f_j (a log-return from its
// start) it reads; e is the instrument's own idiosyncratic term. It folds the loadings of the item's own instrument, in
// the fixed tree. A game multiplies its own reference price by the ratio, in its own currency: the library's finance
// set carries none.
// Source: Sharpe, A Simplified Model for Portfolio Analysis (1963); Elton, Gruber, Brown & Goetzmann, Modern Portfolio
// Theory and Investment Analysis, ch. 7-8.
// Checked against a reference in numpy, which has its own tests.
namespace lusine.finance;
float fair_value(list<struct { relation instrument; float loading; relation factor; }> loadings, float idiosyncratic)
{
return exp((sum(loading * factor.level) where instrument == it) + idiosyncratic);
}
The program¶
A game's tables, written out: four factors (the market, banks, industry and rates), each with its level the host's; the instruments, each with its idiosyncratic term the host's; and the loadings. Two banks load on the market, on banks and against rates, one more on banks than the other; an industrial on the market, industry and against rates; and 41 instruments each load on one factor, unit, held at a level of 1, by a weight from −0.2 to 0.2. values(levels, idiosyncratic) runs one step and gives each instrument's ratio; the planted faults below bind the page's own wrong kernel in the library's place.
FACTORS = ['market', 'banks', 'industry', 'rates', 'unit']
SWEEP = [round(-0.2 + 0.01 * i, 2) for i in range(41)]
LOADINGS = {'bank_a': {'market': 1.0, 'banks': 0.8, 'industry': 0.0, 'rates': -2.0},
'bank_b': {'market': 1.0, 'banks': 0.7, 'industry': 0.0, 'rates': -2.0},
'works': {'market': 1.0, 'banks': 0.0, 'industry': 0.9, 'rates': -1.0}}
LOADINGS.update({f'unit{i}': {'unit': f} for i, f in enumerate(SWEEP)})
def program(kernel='lusine.finance.fair_value', extra=''):
factors = ', '.join(f'{{ name = "{f}" }}' for f in FACTORS)
instruments = ', '.join(f'{{ name = "{i}" }}' for i in LOADINGS)
loadings = ',\n'.join(f' {{ instrument = instruments["{i}"], loading = {float(x)!r}, factor = factors["{f}"] }}'
for i, ls in LOADINGS.items() for f, x in ls.items())
return f'''namespace market;
lusine.version version = {{ {{ number = 1 }} }};
struct factor {{ text name; host float level; }};
table<factor> factors(capacity = 5, key = name) = {{ {factors} }};
struct instrument {{ text name; host float idiosyncratic; float ratio; }};
table<instrument> instruments(capacity = 44, key = name) = {{ {instruments} }};
struct weight {{ relation<instruments> instrument; float loading; relation<factors> factor; }};
table<weight> loadings(capacity = 64) = {{
{loadings},
}};
{extra}
void main() {{ instruments.ratio = {kernel}(loadings, instruments.idiosyncratic); }}
'''
LAW = program()
print(LAW)
def values(levels, idiosyncratic=0.0, text=LAW):
"""{instrument: its fair value's ratio} after one step at the factors' levels ({factor: level}, unit at 1)."""
run = trait_run.Program(['finance/fair_value.trait'], text, {})
run.store('market.factors', 'level', [float(dict(levels, unit=1.0).get(f, 0.0)) for f in FACTORS])
run.store('market.instruments', 'idiosyncratic', np.broadcast_to(idiosyncratic, (44,)))
return dict(zip(LOADINGS, run.steps('market.main', 1, [('market.instruments', 'ratio')])[0][0]))
namespace market;
lusine.version version = { { number = 1 } };
struct factor { text name; host float level; };
table<factor> factors(capacity = 5, key = name) = { { name = "market" }, { name = "banks" }, { name = "industry" }, { name = "rates" }, { name = "unit" } };
struct instrument { text name; host float idiosyncratic; float ratio; };
table<instrument> instruments(capacity = 44, key = name) = { { name = "bank_a" }, { name = "bank_b" }, { name = "works" }, { name = "unit0" }, { name = "unit1" }, { name = "unit2" }, { name = "unit3" }, { name = "unit4" }, { name = "unit5" }, { name = "unit6" }, { name = "unit7" }, { name = "unit8" }, { name = "unit9" }, { name = "unit10" }, { name = "unit11" }, { name = "unit12" }, { name = "unit13" }, { name = "unit14" }, { name = "unit15" }, { name = "unit16" }, { name = "unit17" }, { name = "unit18" }, { name = "unit19" }, { name = "unit20" }, { name = "unit21" }, { name = "unit22" }, { name = "unit23" }, { name = "unit24" }, { name = "unit25" }, { name = "unit26" }, { name = "unit27" }, { name = "unit28" }, { name = "unit29" }, { name = "unit30" }, { name = "unit31" }, { name = "unit32" }, { name = "unit33" }, { name = "unit34" }, { name = "unit35" }, { name = "unit36" }, { name = "unit37" }, { name = "unit38" }, { name = "unit39" }, { name = "unit40" } };
struct weight { relation<instruments> instrument; float loading; relation<factors> factor; };
table<weight> loadings(capacity = 64) = {
{ instrument = instruments["bank_a"], loading = 1.0, factor = factors["market"] },
{ instrument = instruments["bank_a"], loading = 0.8, factor = factors["banks"] },
{ instrument = instruments["bank_a"], loading = 0.0, factor = factors["industry"] },
{ instrument = instruments["bank_a"], loading = -2.0, factor = factors["rates"] },
{ instrument = instruments["bank_b"], loading = 1.0, factor = factors["market"] },
{ instrument = instruments["bank_b"], loading = 0.7, factor = factors["banks"] },
{ instrument = instruments["bank_b"], loading = 0.0, factor = factors["industry"] },
{ instrument = instruments["bank_b"], loading = -2.0, factor = factors["rates"] },
{ instrument = instruments["works"], loading = 1.0, factor = factors["market"] },
{ instrument = instruments["works"], loading = 0.0, factor = factors["banks"] },
{ instrument = instruments["works"], loading = 0.9, factor = factors["industry"] },
{ instrument = instruments["works"], loading = -1.0, factor = factors["rates"] },
{ instrument = instruments["unit0"], loading = -0.2, factor = factors["unit"] },
{ instrument = instruments["unit1"], loading = -0.19, factor = factors["unit"] },
{ instrument = instruments["unit2"], loading = -0.18, factor = factors["unit"] },
{ instrument = instruments["unit3"], loading = -0.17, factor = factors["unit"] },
{ instrument = instruments["unit4"], loading = -0.16, factor = factors["unit"] },
{ instrument = instruments["unit5"], loading = -0.15, factor = factors["unit"] },
{ instrument = instruments["unit6"], loading = -0.14, factor = factors["unit"] },
{ instrument = instruments["unit7"], loading = -0.13, factor = factors["unit"] },
{ instrument = instruments["unit8"], loading = -0.12, factor = factors["unit"] },
{ instrument = instruments["unit9"], loading = -0.11, factor = factors["unit"] },
{ instrument = instruments["unit10"], loading = -0.1, factor = factors["unit"] },
{ instrument = instruments["unit11"], loading = -0.09, factor = factors["unit"] },
{ instrument = instruments["unit12"], loading = -0.08, factor = factors["unit"] },
{ instrument = instruments["unit13"], loading = -0.07, factor = factors["unit"] },
{ instrument = instruments["unit14"], loading = -0.06, factor = factors["unit"] },
{ instrument = instruments["unit15"], loading = -0.05, factor = factors["unit"] },
{ instrument = instruments["unit16"], loading = -0.04, factor = factors["unit"] },
{ instrument = instruments["unit17"], loading = -0.03, factor = factors["unit"] },
{ instrument = instruments["unit18"], loading = -0.02, factor = factors["unit"] },
{ instrument = instruments["unit19"], loading = -0.01, factor = factors["unit"] },
{ instrument = instruments["unit20"], loading = 0.0, factor = factors["unit"] },
{ instrument = instruments["unit21"], loading = 0.01, factor = factors["unit"] },
{ instrument = instruments["unit22"], loading = 0.02, factor = factors["unit"] },
{ instrument = instruments["unit23"], loading = 0.03, factor = factors["unit"] },
{ instrument = instruments["unit24"], loading = 0.04, factor = factors["unit"] },
{ instrument = instruments["unit25"], loading = 0.05, factor = factors["unit"] },
{ instrument = instruments["unit26"], loading = 0.06, factor = factors["unit"] },
{ instrument = instruments["unit27"], loading = 0.07, factor = factors["unit"] },
{ instrument = instruments["unit28"], loading = 0.08, factor = factors["unit"] },
{ instrument = instruments["unit29"], loading = 0.09, factor = factors["unit"] },
{ instrument = instruments["unit30"], loading = 0.1, factor = factors["unit"] },
{ instrument = instruments["unit31"], loading = 0.11, factor = factors["unit"] },
{ instrument = instruments["unit32"], loading = 0.12, factor = factors["unit"] },
{ instrument = instruments["unit33"], loading = 0.13, factor = factors["unit"] },
{ instrument = instruments["unit34"], loading = 0.14, factor = factors["unit"] },
{ instrument = instruments["unit35"], loading = 0.15, factor = factors["unit"] },
{ instrument = instruments["unit36"], loading = 0.16, factor = factors["unit"] },
{ instrument = instruments["unit37"], loading = 0.17, factor = factors["unit"] },
{ instrument = instruments["unit38"], loading = 0.18, factor = factors["unit"] },
{ instrument = instruments["unit39"], loading = 0.19, factor = factors["unit"] },
{ instrument = instruments["unit40"], loading = 0.2, factor = factors["unit"] },
};
void main() { instruments.ratio = lusine.finance.fair_value(loadings, instruments.idiosyncratic); }
Limit: no move is the reference; a rate rise lowers an equity loading against it¶
With every factor at zero each instrument's ratio is exactly 1, its reference price; a rise in rates alone lowers both banks and the industrial, which load against it.
still = values({})
assert all(still[i] == 1.0 for i in ('bank_a', 'bank_b', 'works'))
rise = values({'rates': 0.01})
assert all(rise[i] < 1.0 for i in ('bank_a', 'bank_b', 'works')) and rise['bank_a'] == rise['bank_b']
print('no move:', [float(still[i]) for i in ('bank_a', 'bank_b', 'works')], '; rates up a point:', round(float(rise['bank_a']), 6))
no move: [1.0, 1.0, 1.0] ; rates up a point: 0.980199
Reference: bit for bit, the fold in binary32¶
At a day's levels each instrument's ratio is a binary32 model's bit for bit: its loadings in slot order, each L_j × f_j added in Trait's fixed tree, the idiosyncratic term added, and the library's exp of that, evaluated by the same program's exp on the model's argument (a model calls the program's sequences rather than copying them). The float64 reference agrees within the exp's few ulps.
from lusine.kernels.finance.fair_value import fair_value as reference
DAY = {'market': 0.02, 'banks': 0.01, 'industry': 0.03, 'rates': 0.005}
E = 0.004
day = values(DAY, E)
GROWN = '''namespace grown;
lusine.version version = { { number = 1 } };
struct item { host float move; float ratio; };
lattice<item, 1> items(spacing = 1 m, boundary = wall);
float grown(float move) { return exp(move); }
void main() { items.ratio = grown(items.move); }
'''
def tree(values):
if not values:
return F32(0.0)
if len(values) == 1:
return F32(values[0])
mid = (len(values) + 1) // 2
return F32(tree(values[:mid]) + tree(values[mid:]))
def model(levels, idiosyncratic):
levels = dict(levels, unit=1.0)
moves = [F32(tree([F32(F32(x) * F32(levels.get(f, 0.0))) for f, x in ls.items()]) + F32(idiosyncratic))
for ls in LOADINGS.values()]
run = trait_run.Program([], GROWN, {'grown.items': [len(moves)]})
run.store('grown.items', 'move', np.array(moves, dtype=float))
return dict(zip(LOADINGS, F32(run.steps('grown.main', 1, [('grown.items', 'ratio')])[0][0])))
def model_holds(got, levels, idiosyncratic):
want = model(levels, idiosyncratic)
return all(F32(got[i]) == want[i] for i in LOADINGS)
assert model_holds(day, DAY, E)
for i in ('bank_a', 'bank_b', 'works'):
r = float(reference(np.array([LOADINGS[i][f] for f in FACTORS[:4]]), np.array([DAY[f] for f in FACTORS[:4]]), E))
assert abs(day[i] - r) <= 8 * U * r, (i, day[i], r)
print(f'{len(LOADINGS)} instruments bit for bit with the binary32 model;',
{i: round(float(day[i]), 6) for i in ('bank_a', 'bank_b', 'works')})
44 instruments bit for bit with the binary32 model; {'bank_a': 1.022244, 'bank_b': 1.021222, 'works': 1.047074}
Invariant: log-linear in the factors, and the more exposed moves more¶
Two days' moves compose: the value after both is each one's ratio multiplied, within the exp's rounding. With the market and the banks up, the bank loading more on banks gains more.
d1 = {'market': 0.01, 'banks': -0.02, 'industry': 0.0, 'rates': 0.0005}
d2 = {'market': -0.004, 'banks': 0.03, 'industry': 0.0, 'rates': 0.001}
both = values({f: d1[f] + d2[f] for f in d1})
one, two = values(d1), values(d2)
assert all(abs(both[i] / (one[i] * two[i]) - 1) <= 32 * U for i in ('bank_a', 'bank_b', 'works'))
def exposure_holds(v):
return v['bank_a'] > v['bank_b'] > 1.0
up = values({'market': 0.02, 'banks': 0.01})
assert exposure_holds(up)
print('composes; the market and the banks up:', round(float(up['bank_a']), 6), '>', round(float(up['bank_b']), 6))
composes; the market and the banks up: 1.028396 > 1.027368
Limit: one factor is its exponential¶
An instrument loading on one factor alone moves by e^(L f): the 41 instruments on unit, at a level of 1, are e^L for L from −0.2 to 0.2, each within 4 ulps of float64's.
v = np.array([still[f'unit{i}'] for i in range(len(SWEEP))])
want = np.exp(np.array(SWEEP, dtype=F32).astype(float))
assert np.all(np.abs(v - want) <= 8 * U * want)
fig, ax = plt.subplots(figsize=(7, 3))
ax.plot(SWEEP, v, 'o', ms=3, label='kernel')
ax.plot(SWEEP, np.exp(SWEEP), lw=0.8, label='exp(L)')
ax.set_xlabel('loading on a factor at level 1 (log units)')
ax.set_ylabel('V / V0')
ax.legend()
plt.show()
Planted faults¶
Two wrong kernels over the same list, written here in Trait and bound in the library's place: the loadings' sign turned (a rate rise lifting the equity), and the where left off (every instrument moved by every loading). The rate rise, the exposure and the binary32 model catch them.
TURNED = program('turned', '''
float turned(list<struct { relation instrument; float loading; relation factor; }> loadings, float idiosyncratic)
{
return exp(-(sum(loading * factor.level) where instrument == it) + idiosyncratic);
}
''')
EVERY = program('every', '''
float every(list<struct { relation instrument; float loading; relation factor; }> loadings, float idiosyncratic)
{
return exp(sum(loading * factor.level) + idiosyncratic);
}
''')
W = values({'rates': 0.01}, text=TURNED)
assert W['bank_a'] > 1.0 and not model_holds(values(DAY, E, TURNED), DAY, E)
W = values({'market': 0.02, 'banks': 0.01}, text=EVERY)
assert not exposure_holds(W) and not model_holds(values(DAY, E, EVERY), DAY, E)
print('PLANTED: the turned sign and the missing where are each caught')
PLANTED: the turned sign and the missing where are each caught