Special Relativity in Financial Modeling 1.0.0
Lorentz transforms, spacetime classification, and geodesic price paths for quantitative finance
Loading...
Searching...
No Matches
multi_asset.cpp
Go to the documentation of this file.
1/// @file src/multi_asset.cpp
2/// @brief Implementation of MultiAssetInterval, CorrelationMetric,
3/// MultiAssetLorentz, and PortfolioGeodesic.
4
6#include "srfm/constants.hpp"
7
8#include <Eigen/Cholesky>
9#include <Eigen/Dense>
10#include <algorithm>
11#include <cassert>
12#include <cmath>
13#include <numeric>
14
16
17// ---------------------------------------------------------------------------
18// Helpers
19// ---------------------------------------------------------------------------
20
21namespace {
22
23/// @brief Clamp a double to [lo, hi].
24constexpr double clamp_d(double v, double lo, double hi) noexcept {
25 return v < lo ? lo : (v > hi ? hi : v);
26}
27
28/// @brief Compute per-asset log-returns from consecutive price vectors.
29Eigen::VectorXd log_returns(const Eigen::VectorXd& prev,
30 const Eigen::VectorXd& curr) noexcept {
31 assert(prev.size() == curr.size());
32 Eigen::VectorXd ret(prev.size());
33 for (Eigen::Index i = 0; i < prev.size(); ++i) {
34 if (prev[i] > 0.0 && curr[i] > 0.0) {
35 ret[i] = std::log(curr[i] / prev[i]);
36 } else {
37 ret[i] = 0.0;
38 }
39 }
40 return ret;
41}
42
43/// @brief Compute scalar Lorentz factor γ = 1/sqrt(1 − β²).
44double lorentz_gamma(double beta) noexcept {
45 const double b2 = beta * beta;
46 if (b2 >= 1.0) return 1.0 / std::sqrt(1.0 - constants::BETA_MAX_SAFE *
48 return 1.0 / std::sqrt(1.0 - b2);
49}
50
51} // anonymous namespace
52
53// ---------------------------------------------------------------------------
54// MultiAssetInterval
55// ---------------------------------------------------------------------------
56
57std::optional<double>
59 const MultiAssetEvent& b,
60 const Eigen::MatrixXd& metric) noexcept
61{
62 if (!a.is_valid() || !b.is_valid()) return std::nullopt;
63 if (a.n_assets() != b.n_assets()) return std::nullopt;
64
65 const std::size_t N = a.n_assets();
66 const Eigen::Index dim = static_cast<Eigen::Index>(N + 1);
67 if (metric.rows() != dim || metric.cols() != dim) return std::nullopt;
68
69 // Build the displacement 4-vector: Δx^μ = (Δt, ΔP_1, ..., ΔP_N).
70 Eigen::VectorXd dx(dim);
71 const double dt = static_cast<double>(b.timestamp - a.timestamp) / 1000.0; // ms → s
72 dx[0] = dt;
73 for (std::size_t i = 0; i < N; ++i) {
74 dx[static_cast<Eigen::Index>(i + 1)] = b.prices[i] - a.prices[i];
75 }
76
77 // ds² = g_μν Δx^μ Δx^ν
78 const double ds2 = (dx.transpose() * metric * dx).value();
79 return ds2;
80}
81
83MultiAssetInterval::classify(double ds2) noexcept {
84 if (std::abs(ds2) <= kLightlikeEps) return IntervalType::Lightlike;
85 if (ds2 < 0.0) return IntervalType::Timelike;
86 return IntervalType::Spacelike;
87}
88
89std::optional<std::pair<double, MultiAssetInterval::IntervalType>>
91 const MultiAssetEvent& b,
92 const Eigen::MatrixXd& metric) noexcept
93{
94 auto ds2 = compute(a, b, metric);
95 if (!ds2) return std::nullopt;
96 return std::make_pair(*ds2, classify(*ds2));
97}
98
99// ---------------------------------------------------------------------------
100// CorrelationMetric
101// ---------------------------------------------------------------------------
102
104 : n_assets_(n_assets)
105 , cfg_(cfg)
106{
107 assert(n_assets_ > 0);
108 log_returns_window_.resize(cfg_.window_size);
109 for (auto& v : log_returns_window_) {
110 v.setZero(static_cast<Eigen::Index>(n_assets_));
111 }
112 prev_prices_.setZero(static_cast<Eigen::Index>(n_assets_));
113}
114
115std::optional<Eigen::MatrixXd>
116CorrelationMetric::update(const std::vector<double>& prices) {
117 if (prices.size() != n_assets_) return std::nullopt;
118
119 // Convert to Eigen vector.
120 Eigen::VectorXd p(static_cast<Eigen::Index>(n_assets_));
121 for (std::size_t i = 0; i < n_assets_; ++i)
122 p[static_cast<Eigen::Index>(i)] = prices[i];
123
124 if (!has_prev_) {
125 prev_prices_ = p;
126 has_prev_ = true;
127 obs_count_++;
128 return std::nullopt; // Need at least 2 observations.
129 }
130
131 // Compute log-return and push into circular buffer.
132 Eigen::VectorXd ret = log_returns(prev_prices_, p);
133 log_returns_window_[window_head_] = ret;
134 window_head_ = (window_head_ + 1) % cfg_.window_size;
135 obs_count_++;
136 prev_prices_ = p;
137
138 if (obs_count_ < 2) return std::nullopt;
139
140 metric_dirty_ = true;
141 recompute_metric();
142 return current_metric_;
143}
144
145std::optional<Eigen::MatrixXd>
147 return current_metric_;
148}
149
150std::optional<Eigen::MatrixXd>
152 if (!current_metric_) return std::nullopt;
153 const Eigen::Index N = static_cast<Eigen::Index>(n_assets_);
154 // Extract spatial block and normalise by diagonal (variances).
155 Eigen::MatrixXd spatial = current_metric_->block(1, 1, N, N);
156 Eigen::VectorXd variances = spatial.diagonal();
157 Eigen::MatrixXd corr(N, N);
158 for (Eigen::Index i = 0; i < N; ++i) {
159 for (Eigen::Index j = 0; j < N; ++j) {
160 const double denom = std::sqrt(variances[i] * variances[j]);
161 corr(i, j) = (denom > 1e-12) ? spatial(i, j) / denom : (i == j ? 1.0 : 0.0);
162 }
163 }
164 return corr;
165}
166
168 obs_count_ = 0;
169 window_head_ = 0;
170 has_prev_ = false;
171 metric_dirty_ = true;
172 current_metric_ = std::nullopt;
173 for (auto& v : log_returns_window_)
174 v.setZero(static_cast<Eigen::Index>(n_assets_));
175}
176
177Eigen::MatrixXd CorrelationMetric::compute_covariance() const {
178 const Eigen::Index N = static_cast<Eigen::Index>(n_assets_);
179 // Collect valid observations (up to window_size or obs_count-1).
180 const std::size_t n_obs = std::min(obs_count_ - 1, cfg_.window_size);
181 if (n_obs < 2) return Eigen::MatrixXd::Identity(N, N) * 1e-4;
182
183 // Compute sample mean.
184 Eigen::VectorXd mean = Eigen::VectorXd::Zero(N);
185 for (std::size_t k = 0; k < n_obs; ++k) {
186 // Walk backward from (window_head_ - 1) to access most recent n_obs.
187 const std::size_t idx = (window_head_ + cfg_.window_size - 1 - k) % cfg_.window_size;
188 mean += log_returns_window_[idx];
189 }
190 mean /= static_cast<double>(n_obs);
191
192 // Compute sample covariance.
193 Eigen::MatrixXd cov = Eigen::MatrixXd::Zero(N, N);
194 for (std::size_t k = 0; k < n_obs; ++k) {
195 const std::size_t idx = (window_head_ + cfg_.window_size - 1 - k) % cfg_.window_size;
196 const Eigen::VectorXd z = log_returns_window_[idx] - mean;
197 cov += z * z.transpose();
198 }
199 cov /= static_cast<double>(n_obs - 1);
200
201 return cov;
202}
203
204void CorrelationMetric::recompute_metric() {
205 const Eigen::Index N = static_cast<Eigen::Index>(n_assets_);
206 const Eigen::Index dim = N + 1;
207
208 Eigen::MatrixXd cov = compute_covariance();
209
210 // Regularise if not positive definite.
211 Eigen::LLT<Eigen::MatrixXd> llt(cov);
212 if (llt.info() != Eigen::Success) {
213 cov += Eigen::MatrixXd::Identity(N, N) * cfg_.regularisation_eps;
214 }
215
216 // Build (N+1)×(N+1) Lorentzian metric.
217 Eigen::MatrixXd g = Eigen::MatrixXd::Zero(dim, dim);
218 g(0, 0) = -(cfg_.c_market * cfg_.c_market);
219 g.block(1, 1, N, N) = cov;
220
221 current_metric_ = g;
222 metric_dirty_ = false;
223}
224
225// ---------------------------------------------------------------------------
226// MultiAssetLorentz
227// ---------------------------------------------------------------------------
228
229std::optional<MultiAssetLorentz::TransformResult>
231 const MultiAssetEvent& b,
232 const Eigen::MatrixXd& metric) noexcept
233{
234 if (!a.is_valid() || !b.is_valid()) return std::nullopt;
235 if (a.n_assets() != b.n_assets()) return std::nullopt;
236
237 const std::size_t N = a.n_assets();
238 const Eigen::Index dim = static_cast<Eigen::Index>(N + 1);
239 if (metric.rows() != dim || metric.cols() != dim) return std::nullopt;
240
241 const double dt = static_cast<double>(b.timestamp - a.timestamp) / 1000.0;
242 if (std::abs(dt) < 1e-12) return std::nullopt;
243
244 // Compute per-asset β_i = ΔP_i / (c · Δt).
245 const double c = std::sqrt(std::abs(metric(0, 0)));
246 std::vector<double> betas(N);
247 std::vector<double> gammas(N);
248 for (std::size_t i = 0; i < N; ++i) {
249 const double dp = b.prices[i] - a.prices[i];
250 const double beta = clamp_d(dp / (c * dt),
253 betas[i] = beta;
254 gammas[i] = lorentz_gamma(beta);
255 }
256
257 // Portfolio β (metric-weighted).
258 const double beta_p = portfolio_beta(betas, metric);
259 const double gamma_p = lorentz_gamma(beta_p);
260
261 // Apply γ_portfolio to each asset's price adjustment.
262 std::vector<double> adjusted(N);
263 for (std::size_t i = 0; i < N; ++i) {
264 adjusted[i] = gammas[i] * (b.prices[i] - a.prices[i]);
265 }
266
267 return TransformResult{
268 adjusted, beta_p, LorentzFactor{gamma_p}, betas, gammas
269 };
270}
271
272double
273MultiAssetLorentz::portfolio_beta(const std::vector<double>& betas,
274 const Eigen::MatrixXd& metric) noexcept
275{
276 const std::size_t N = betas.size();
277 if (N == 0) return 0.0;
278 const Eigen::Index dim = metric.rows();
279 if (dim < static_cast<Eigen::Index>(N + 1)) return 0.0;
280
281 // Build β vector (spatial components only).
282 Eigen::VectorXd b(static_cast<Eigen::Index>(N));
283 for (std::size_t i = 0; i < N; ++i)
284 b[static_cast<Eigen::Index>(i)] = betas[i];
285
286 // Extract spatial block of metric.
287 const Eigen::MatrixXd g_spatial = metric.block(1, 1,
288 static_cast<Eigen::Index>(N),
289 static_cast<Eigen::Index>(N));
290
291 // β_portfolio² = g_ij β_i β_j
292 const double b2 = (b.transpose() * g_spatial * b).value();
293 if (b2 <= 0.0) return 0.0;
294
295 return clamp_d(std::sqrt(b2), 0.0, constants::BETA_MAX_SAFE);
296}
297
298// ---------------------------------------------------------------------------
299// PortfolioGeodesic
300// ---------------------------------------------------------------------------
301
302std::vector<PortfolioGeodesic::GeodesicStep>
304 const Eigen::VectorXd& four_velocity,
305 const Eigen::MatrixXd& metric,
306 std::size_t n_steps,
307 double dt) noexcept
308{
309 if (!initial.is_valid() || n_steps == 0 || std::abs(dt) < 1e-15)
310 return {};
311
312 const std::size_t N = initial.n_assets();
313 const Eigen::Index dim = static_cast<Eigen::Index>(N + 1);
314 if (four_velocity.size() != dim) return {};
315 if (metric.rows() != dim || metric.cols() != dim) return {};
316
317 std::vector<GeodesicStep> path;
318 path.reserve(n_steps);
319
320 // Current position in spacetime.
321 Eigen::VectorXd x(dim);
322 x[0] = static_cast<double>(initial.timestamp) / 1000.0; // ms → s
323 for (std::size_t i = 0; i < N; ++i)
324 x[static_cast<Eigen::Index>(i + 1)] = initial.prices[i];
325
326 // For a flat (constant metric) manifold the geodesic is a straight line:
327 // x^μ(τ) = x^μ(0) + u^μ · τ
328 // Christoffel symbols vanish so there is no acceleration term.
329 double tau = 0.0;
330 double arc_length = 0.0;
331
332 for (std::size_t step = 0; step < n_steps; ++step) {
333 // Euler step.
334 x += four_velocity * dt;
335 tau += dt;
336
337 // Compute proper arc length element: ds² = g_μν u^μ u^ν · dτ²
338 const double ds2_dot = (four_velocity.transpose() * metric * four_velocity).value();
339 const double ds = std::sqrt(std::abs(ds2_dot)) * std::abs(dt);
340 arc_length += ds;
341
342 // Build MultiAssetEvent from current x.
344 ev.timestamp = static_cast<int64_t>(x[0] * 1000.0); // s → ms
345 ev.prices.resize(N);
346 for (std::size_t i = 0; i < N; ++i)
347 ev.prices[i] = x[static_cast<Eigen::Index>(i + 1)];
348 if (!initial.symbols.empty()) ev.symbols = initial.symbols;
349 if (!initial.volumes.empty()) ev.volumes = initial.volumes;
350
351 path.push_back({ev, tau, arc_length, 0.0 /* filled by deviation_series */});
352 }
353 return path;
354}
355
356std::vector<double>
358 const std::vector<GeodesicStep>& predicted,
359 const std::vector<MultiAssetEvent>& actual) noexcept
360{
361 if (predicted.size() != actual.size()) return {};
362 std::vector<double> devs;
363 devs.reserve(predicted.size());
364
365 for (std::size_t i = 0; i < predicted.size(); ++i) {
366 const auto& pred_prices = predicted[i].event.prices;
367 const auto& actual_prices = actual[i].prices;
368 if (pred_prices.size() != actual_prices.size()) {
369 devs.push_back(0.0);
370 continue;
371 }
372 double sq_sum = 0.0;
373 for (std::size_t j = 0; j < pred_prices.size(); ++j) {
374 const double d = pred_prices[j] - actual_prices[j];
375 sq_sum += d * d;
376 }
377 devs.push_back(std::sqrt(sq_sum));
378 }
379 return devs;
380}
381
382std::vector<Eigen::VectorXd>
383PortfolioGeodesic::portfolio_weights(const std::vector<GeodesicStep>& steps,
384 const Eigen::VectorXd& four_velocity,
385 double gross_exposure) noexcept
386{
387 if (steps.empty() || four_velocity.size() < 2) return {};
388 const Eigen::Index N = four_velocity.size() - 1; // spatial dimensions
389
390 // Extract spatial components of the four-velocity.
391 const Eigen::VectorXd u_spatial = four_velocity.tail(N);
392 const double norm = u_spatial.lpNorm<1>(); // L1 norm for weight normalisation
393
394 std::vector<Eigen::VectorXd> weights;
395 weights.reserve(steps.size());
396
397 for (std::size_t i = 0; i < steps.size(); ++i) {
398 Eigen::VectorXd w(N);
399 if (norm < 1e-12) {
400 w.setZero();
401 } else {
402 w = u_spatial * (gross_exposure / norm);
403 }
404 weights.push_back(w);
405 }
406 return weights;
407}
408
409} // namespace srfm::multi_asset
std::optional< Eigen::MatrixXd > update(const std::vector< double > &prices)
Add a new bar observation and update the metric.
std::optional< Eigen::MatrixXd > current_metric() const noexcept
Return the current metric tensor (last computed).
CorrelationMetric(std::size_t n_assets, Config cfg={})
Construct with given configuration.
std::optional< Eigen::MatrixXd > correlation_matrix() const noexcept
Return the rolling correlation matrix (spatial block only).
void reset() noexcept
Reset the window (clears all observations).
static std::optional< double > compute(const MultiAssetEvent &a, const MultiAssetEvent &b, const Eigen::MatrixXd &metric) noexcept
Compute ds² between two N-asset spacetime events.
static IntervalType classify(double ds2) noexcept
Classify a pre-computed ds² value.
static std::optional< std::pair< double, IntervalType > > compute_and_classify(const MultiAssetEvent &a, const MultiAssetEvent &b, const Eigen::MatrixXd &metric) noexcept
Compute and classify in one call.
static double portfolio_beta(const std::vector< double > &betas, const Eigen::MatrixXd &metric) noexcept
Compute portfolio β from individual asset velocities and the metric.
static std::optional< TransformResult > transform(const MultiAssetEvent &a, const MultiAssetEvent &b, const Eigen::MatrixXd &metric) noexcept
Apply the multi-asset Lorentz transform.
static std::vector< double > deviation_series(const std::vector< GeodesicStep > &predicted, const std::vector< MultiAssetEvent > &actual) noexcept
Compute the geodesic deviation between the predicted and actual path.
static std::vector< Eigen::VectorXd > portfolio_weights(const std::vector< GeodesicStep > &steps, const Eigen::VectorXd &four_velocity, double gross_exposure=1.0) noexcept
Compute portfolio weights along the geodesic path.
static std::vector< GeodesicStep > integrate(const MultiAssetEvent &initial, const Eigen::VectorXd &four_velocity, const Eigen::MatrixXd &metric, std::size_t n_steps, double dt) noexcept
Compute the geodesic from an initial event with given four-velocity.
Physical and financial constants for the SRFM system.
Multi-asset spacetime extension for the SRFM library.
static constexpr double BETA_MAX_SAFE
Definition constants.hpp:17
IntervalType
Causal character of a spacetime interval.
Definition manifold.hpp:61
Lorentz factor γ = 1/√(1−β²). Always ≥ 1.0 for valid beta.
Definition types.hpp:33
std::size_t window_size
Rolling window (number of bars).
double regularisation_eps
Ridge regularisation for ill-conditioned Σ.
A snapshot of N correlated financial assets at a point in time.
int64_t timestamp
Unix epoch milliseconds.
std::vector< std::string > symbols
Asset ticker symbols (for labelling).
std::vector< double > prices
Mid-prices for each asset.
std::vector< double > volumes
Traded volumes for each asset.
Result of a multi-asset Lorentz transform.