Pinned Post
Quant in Elixir: ein Portfolio-Risiko-Score ohne NumPy
- Link abrufen
- X
- Andere Apps
Bei Fiatbitcoin zeigt das Risk-Dashboard ein paar Zahlen an, die nach Finanzabteilung klingen: annualisierte Volatilität, Value-at-Risk, Maximum Drawdown, ein Konzentrationsindex und obendrauf ein Health-Score von 0 bis 100. Solche Kennzahlen sind das Handwerk eines Quants, kurz für quantitative analyst, also jemand, der Märkte mit Statistik und Code analysiert und daraus Risiko- und Handelsmodelle baut. Der Reflex, sobald jemand “Finanzmathematik” sagt, ist Python: NumPy, pandas, ein eigener Service, eine Datenpipeline dorthin und wieder zurück.
Ich wollte keine zweite Runtime nur dafür, eine Standardabweichung zu
rechnen. Die Frage war, wie weit man mit purem Elixir kommt, mit
Enum und dem :math-Modul aus der
Erlang-Standardbibliothek. Die Antwort: erstaunlich weit. Das ganze
Risk-Modul sind ein paar hundert Zeilen, kein Nx, keine numerische
Library. Dieser Artikel zeigt, was die Mathematik eigentlich ist, wie
sie als Enum-Pipeline aussieht, und wo die ehrlichen Grenzen liegen,
also die Stellen, bei denen ein echter Quant zusammenzucken würde.
Warum kein Python
Die Versuchung ist real. Es gibt fertige, ausgereifte Bibliotheken,
und niemand wird dafür schief angeschaut, numpy.std()
aufzurufen. Aber jede dieser Bibliotheken zieht eine zweite
Laufzeitumgebung nach sich, einen Serialisierungs-Layer, ein
Deployment-Artefakt mehr. Für ein Dashboard, das täglich ein paar
hundert Preispunkte verarbeitet, ist das viel Apparat für wenig
Rechnung.
Beim genaueren Hinsehen sind die Kennzahlen Lehrbuchformeln. Returns
sind Differenzen aufeinanderfolgender Preise. Volatilität ist deren
Standardabweichung. Korrelation ist eine Kovarianz, geteilt durch zwei
Standardabweichungen. Das sind keine Tensoroperationen, das sind
Faltungen über eine Liste von Zahlen. Und Listen über Zahlen falten ist
genau das, wofür Enum gemacht ist.
Volatilität: ein gleitendes Fenster und eine Standardabweichung
Der Einstieg sind die täglichen Returns. Man braucht jeweils zwei aufeinanderfolgende Preise und ihre prozentuale Differenz. Das gleitende Zweierfenster gibt es in Elixir geschenkt:
defp calculate_returns(prices) do
prices
|> Enum.chunk_every(2, 1, :discard)
|> Enum.map(fn [prev, curr] ->
# Calculate percentage return: (curr - prev) / prev
prev_float = Decimal.to_float(prev)
curr_float = Decimal.to_float(curr)
if prev_float > 0 do
(curr_float - prev_float) / prev_float
else
0.0
end
end)
end
chunk_every(2, 1, :discard) schiebt ein Fenster der
Breite zwei um jeweils einen Schritt weiter und wirft den
unvollständigen Rest am Ende weg. Kein manuelles Puffern, kein
Index-Gefummel. Aus einer Preisliste wird eine Return-Liste.
Die Standardabweichung darüber ist die Formel, direkt hingeschrieben:
defp calculate_std_dev(returns) do
mean = Enum.sum(returns) / length(returns)
variance =
returns
|> Enum.map(fn r -> :math.pow(r - mean, 2) end)
|> Enum.sum()
|> Kernel./(length(returns))
:math.sqrt(variance)
end
Mittelwert, quadrierte Abweichungen, deren Mittel, Wurzel. Das liest
sich wie die Definition aus dem Statistik-Skript. Annualisiert wird am
Ende mit volatility * :math.sqrt(365), weil tägliche
Streuung sich mit der Wurzel der Zeit skaliert. Kein NumPy, nur
:math.sqrt und :math.pow.
Value-at-Risk: zwei Wege, einer ehrlicher als der andere
Value-at-Risk beantwortet “wie viel kann ich an einem schlechten Tag verlieren”. Es gibt zwei gängige Methoden, und das Modul kann beide.
Die historische Methode sortiert die beobachteten Returns und nimmt das passende Perzentil. Für 95 Prozent Konfidenz ist das der Punkt, unter dem die schlechtesten 5 Prozent der Tage liegen:
defp calculate_historical_var(returns, confidence_level, portfolio_value) do
sorted_returns = Enum.sort(returns)
index = trunc((1 - confidence_level) * length(sorted_returns))
percentile_return = Enum.at(sorted_returns, index, 0.0)
# VaR is the loss amount (positive number)
abs(percentile_return * portfolio_value)
end
Keine Interpolation, kein Modell, nur die tatsächlich beobachtete Verteilung. Die parametrische Methode ist eleganter und gefährlicher: Sie nimmt eine Normalverteilung an und multipliziert die tägliche Volatilität mit einem Z-Score. Die Z-Scores stehen als Funktionsklauseln da, was in Elixir hübsch lesbar ist:
defp get_z_score(0.90), do: 1.28
defp get_z_score(0.95), do: 1.65
defp get_z_score(0.99), do: 2.33
defp get_z_score(_), do: 1.65
Genau hier liegt die erste ehrliche Schwäche. Bitcoin-Returns sind nicht normalverteilt, sie haben fette Ränder. Extreme Tage kommen häufiger vor, als eine Glockenkurve erlaubt. Die parametrische Variante unterschätzt das echte Risiko am 99-Prozent-Punkt deshalb spürbar. Im Dashboard ist die historische Methode der Default, und das ist die richtige Wahl.
Korrelation: Pearson als Pipeline
Wie stark laufen zwei Assets im Gleichschritt? Das ist der
Pearson-Korrelationskoeffizient, eine Kovarianz im Zähler und das
Produkt zweier Standardabweichungen im Nenner. Enum.zip
paart die beiden Return-Reihen, der Rest ist wieder map und sum:
defp pearson_correlation(returns1, returns2) do
n = length(returns1)
mean1 = Enum.sum(returns1) / n
mean2 = Enum.sum(returns2) / n
numerator =
Enum.zip(returns1, returns2)
|> Enum.map(fn {r1, r2} -> (r1 - mean1) * (r2 - mean2) end)
|> Enum.sum()
variance1 = returns1 |> Enum.map(fn r -> :math.pow(r - mean1, 2) end) |> Enum.sum()
variance2 = returns2 |> Enum.map(fn r -> :math.pow(r - mean2, 2) end) |> Enum.sum()
denominator = :math.sqrt(variance1 * variance2)
if denominator == 0 do
0.0
else
numerator / denominator
end
end
Der Guard auf denominator == 0 ist kein Schmuck. Liegt
eine Reihe völlig flach, ist die Varianz null, und ohne den Guard käme
NaN heraus, das sich durch alle weiteren Berechnungen frisst.
Konzentration: der HHI ist nur eine Summe von Quadraten
Der Herfindahl-Hirschman-Index misst, wie sehr ein Portfolio auf wenige Positionen konzentriert ist. Man nimmt die Wertanteile jeder Position, quadriert sie und summiert. Ein Index nahe eins heißt Klumpenrisiko, ein kleiner Index heißt breit gestreut:
defp compute_hhi(holdings) do
total_value =
holdings
|> Enum.map(&holding_value/1)
|> Enum.sum()
if total_value == 0 do
{:error, :zero_portfolio_value}
else
shares = Enum.map(holdings, &holding_share(&1, total_value))
hhi = shares |> Enum.map(fn s -> :math.pow(s.share, 2) end) |> Enum.sum()
{:ok, %{hhi: Decimal.from_float(hhi), interpretation: interpret_hhi(hhi), warning: hhi >= 0.25}}
end
end
Map-Reduce in Reinform: einmal über die Werte für die Summe, einmal über die Anteile für die Quadrate. Die Interpretation (“diversified”, “moderate_concentration”, “high_concentration”) sind drei Schwellen als Funktionsklauseln.
Der Health-Score: vier Zahlen, vier Gewichte
Am Ende will das Dashboard eine einzige Zahl, an der man ablesen kann, ob es dem Portfolio gut geht. Der Health-Score ist ein gewichteter Durchschnitt aus vier Komponenten:
total_score =
volatility_score * 0.3 +
concentration_score * 0.3 +
drawdown_score * 0.2 +
correlation_score * 0.2
Jede Komponente übersetzt ihre Rohzahl über eine Reihe von Schwellen in Punkte von 0 bis 90. Für die Volatilität sieht das so aus:
cond do
avg_volatility < 0.3 -> 90.0
avg_volatility < 0.5 -> 75.0
avg_volatility < 0.8 -> 60.0
avg_volatility < 1.2 -> 40.0
true -> 20.0
end
Das ist bewusst keine Black Box und kein Machine Learning. Es sind lesbare Schwellen, die jeder nachvollziehen und neu kalibrieren kann, ohne ein Modell neu zu trainieren. Wer findet, dass 50 Prozent Jahresvolatilität bei Bitcoin noch “gut” sein sollten, ändert eine Zahl.
Lessons Learned
Drei Dinge, die sich bewährt haben, und eines, bei dem ich ehrlich sein muss.
1. :math und Enum reichen weiter,
als man denkt.
Für deskriptive Statistik über ein paar hundert Punkte braucht es kein Nx und keinen Python-Sidecar. Wurzel, Potenz, Summe, das ganze Modul kommt mit der Standardbibliothek aus. Die schwerere Artillerie lohnt sich erst, wenn man wirklich Matrizen über Tausende Assets multipliziert.
2. Die Pipeline liest sich wie die Formel.
chunk_every für das Fenster, zip für die
Paare, map und sum für den Rest. Wer die
Statistikdefinition kennt, liest den Code ohne Kommentare. Das ist mir
mehr wert als jede vektorisierte Abkürzung, die man drei Wochen später
selbst nicht mehr versteht.
3. Decimal an den Rändern, Float in der Mitte.
Die Preise und Ergebnisse liegen als Decimal in der
Datenbank, weil dort Genauigkeit zählt. :math rechnet aber
nur mit Floats. Also wird an der Grenze konvertiert:
Decimal.to_float rein in die Rechnung,
Decimal.from_float zurück in die Persistenz. Diese Grenze
sauber zu halten erspart eine Menge subtiler Fehler.
4. Was ich nachschärfen würde: die Annahmen.
Die Mathematik stimmt, die Annahmen darunter sind die Kompromisse.
Der parametrische VaR unterstellt eine Normalverteilung, die es bei
Bitcoin nicht gibt. Der Korrelations-Score nimmt aus einem
Mehr-Asset-Portfolio nur die ersten beiden Symbole
([sym1, sym2 | _]) und ignoriert den Rest. Die Fenster sind
fest auf 90 oder 365 Tage verdrahtet. Und der Konzentrationsindex
rechnet mit dem durchschnittlichen Kaufpreis aus den Holdings, nicht mit
dem aktuellen Marktpreis, kann also veralten. Für ein bitcoinlastiges
Portfolio, das ohnehin meist aus einer dominanten Position besteht, ist
all das vertretbar. Der Score ist gut genug, um “dein Portfolio ist
stark konzentriert” zu sagen, aber er ist keine Risiko-Engine, mit der
man eine Option bepreist. Diesen Unterschied sollte man offen
halten.
Wenn euch eine Stelle genauer interessiert, der Drawdown über einen
reduce mit laufendem Maximum, die DCA-Heatmap oder wie der
Score täglich als Snapshot persistiert wird, schreibt es in die
Kommentare. Ich gehe gerne tiefer.
- Link abrufen
- X
- Andere Apps

Kommentare