The Relationship Between Market Space and Profit Distribution in Arbitrage

PDF

Abstract

This concept paper proposes that the area of a marketplace, modeled as a non-equilateral triangle, is related to the profit distribution between unequal-sized firms in arbitrage situations. Arbitrage, the practice of exploiting price differences across markets, is a fundamental concept in finance and economics, and most treatments focus on the price discrepancy itself. We introduce a geometric framework, using integral calculus, in which the partition of a triangular market space determines how a profit density is divided among market segments. Under a constant profit density, each segment’s share of profit equals its share of the area, which gives the hypothesis a testable form. As an exploratory illustration, we compare price-volume curves and ARIMA forecasts for NYMEX WTI and ICE Brent crude oil futures from January 2022 to January 2024. The comparison recovers the familiar Brent-WTI price differential, which reflects differences in crude grade and delivery location and therefore carries basis risk. The paper does not yet test the hypothesis formally; we outline the data on firm size and segment-level profits that such a test would require.

Introduction

Arbitrage, the practice of exploiting price differences in different markets, is central to financial theory. This paper explores the hypothesis that the area of the marketplace, modeled as a non-equilateral triangle, is related to the profit distribution between unequal-sized firms in arbitrage situations. We propose a geometric framework, using integral calculus, to describe how market structure could shape economic interactions and arbitrage opportunities.

Crude oil illustrates the setting well. The same commodity trades on several exchanges (for example, WTI on NYMEX and Brent on ICE), and the persistent price differential between the two benchmarks has a large literature of its own. We use these two contracts as an exploratory case.

Hypothesis and Theoretical Framework

\(H_0\): The area of the marketplace, represented as a non-equilateral triangle, has no significant effect on the profit distribution between unequal-sized firms in an arbitrage situation.

\(H_1\): The area of the marketplace, represented as a non-equilateral triangle, is significantly related to the profit distribution between unequal-sized firms in an arbitrage situation.

The theoretical framework draws on geometric modeling and integral calculus, and is motivated by geometric arbitrage theory. The marketplace is modeled as a non-equilateral triangle in a Cartesian coordinate system, where the area of the triangle represents the market space. This representation lets us state how the partition of that space among market segments translates into a partition of profits.

Mathematical Framework

Let the marketplace be represented by a non-equilateral triangle \(\triangle ABC\) in a Cartesian plane. Points \(A\), \(B\), and \(C\) denote the vertices of the market space, with each side representing a different market boundary.

Figure 1: \(\triangle ACD\) represents Market A, and \(\triangle ADB\) represents Market B

A line segment \(AD\), where \(D\) lies on \(BC\), divides the triangle into two segments: Market A (\(\triangle ACD\)) and Market B (\(\triangle ADB\)). The triangle is positioned on a Cartesian coordinate plane to facilitate the analysis of vertical integration and spatial relationships.

To quantify the total trade potential within the marketplace, we use the geometric area of \(\triangle ABC\), which represents the market space bounded by firms or segments. A larger area reflects a broader scope for arbitrage activity and profit realization. For a triangle with vertices \(A = (x_A, y_A)\), \(B = (x_B, y_B)\), and \(C = (x_C, y_C)\), the area follows from the determinant (shoelace) formula:

\[\begin{equation} \text{Area}(\triangle ABC) = \frac{1}{2}\left|x_A(y_B-y_C) + x_B(y_C-y_A) + x_C(y_A-y_B)\right| \end{equation}\]

Assume the profit in each segment is proportional to its area. Let \(P_A\) and \(P_B\) denote the profits in Market A and Market B:

\[\begin{equation} P_A = k \cdot \text{Area}(\triangle ACD) \end{equation}\]

\[\begin{equation} P_B = k \cdot \text{Area}(\triangle ADB) \end{equation}\]

where \(k\) is a constant reflecting market conditions and efficiency. It follows that each market’s profit share equals its area share, \(P_A / (P_A + P_B) = \text{Area}(\triangle ACD) / \text{Area}(\triangle ABC)\), independent of \(k\).

The dividing segment \(AD\), with \(A = (x_A, y_A)\) and \(D = (x_D, y_D)\), lies on the line

\[\begin{equation} y - y_A = m(x - x_A), \qquad m = \frac{y_D - y_A}{x_D - x_A} \end{equation}\]

provided \(x_D \neq x_A\) (if \(AD\) is vertical, the line is simply \(x = x_A\)). The segment areas follow from the same determinant formula, for example

\[\begin{equation} \text{Area}(\triangle ADB) = \frac{1}{2}\left|x_A(y_D-y_B) + x_D(y_B-y_A) + x_B(y_A-y_D)\right| \end{equation}\]

and, because \(D\) lies on \(BC\), the two segments exhaust the market:

\[\begin{equation} \text{Area}(\triangle ABC) = \text{Area}(\triangle ACD) + \text{Area}(\triangle ADB) \end{equation}\]

The area can equivalently be written as an iterated integral. Orient the triangle so that \(BC\) is the base; then

\[\begin{equation} \text{Area}(\triangle ABC) = \int_{x_C}^{x_B}\left(\int_{y_{\text{bottom}}(x)}^{y_{\text{top}}(x)}dy\right)dx \end{equation}\]

where \(y_{\text{bottom}}(x)\) is the line through \(B\) and \(C\), and \(y_{\text{top}}(x)\) is the line through \(C\) and \(A\) for \(x \le x_A\) and the line through \(A\) and \(B\) for \(x \ge x_A\).

Now let a profit density \(p(x,y)\) be defined over the market space. With the dividing point \(D\) directly below \(A\) (\(x_D = x_A\)), the profits in each market are

\[\begin{equation} P_A = \iint_{\triangle ACD} p(x,y)\,dA = \int_{x_C}^{x_D}\int_{y_{\text{bottom}}(x)}^{y_{\text{top}}(x)} p(x,y)\,dy\,dx \end{equation}\]

\[\begin{equation} P_B = \iint_{\triangle ADB} p(x,y)\,dA = \int_{x_D}^{x_B}\int_{y_{\text{bottom}}(x)}^{y_{\text{top}}(x)} p(x,y)\,dy\,dx \end{equation}\]

When \(p(x,y) = k\) is constant, these reduce to equations (2) and (3). A non-constant density lets profit concentrate in part of the market space, so area share and profit share can diverge; measuring that divergence is one way to test \(H_1\) against data.

The boundary between the two markets can be parameterized as

\[\begin{equation} \mathbf{r}(t) = (1-t)\mathbf{A} + t\mathbf{D}, \qquad 0 \le t \le 1 \end{equation}\]

and the profit density accumulated along that boundary is the line integral

\[\begin{equation} \int_{AD} p(x,y)\,ds = \int_{0}^{1} p(\mathbf{r}(t))\,\lVert\mathbf{r}'(t)\rVert\,dt, \qquad \lVert\mathbf{r}'(t)\rVert = \lVert \mathbf{D} - \mathbf{A} \rVert \end{equation}\]

When does profit net to zero?

Consider a linear profit potential \(\pi(x,y) = -x + y\), in which price (along \(x\)) reduces profit and access or demand (along \(y\)) increases it. For a linear function over any region, the integral equals the area times the function’s value at the centroid \((\bar{x}, \bar{y})\):

\[\begin{equation} \iint_{\triangle ABC} (-x + y)\,dA = \text{Area}(\triangle ABC)\,(\bar{y} - \bar{x}), \qquad (\bar{x}, \bar{y}) = \left(\tfrac{x_A + x_B + x_C}{3}, \tfrac{y_A + y_B + y_C}{3}\right) \end{equation}\]

The total is zero only when the centroid lies on the line \(y = x\). For the triangle in Figure 2 below, with \(A=(0,3)\), \(B=(6,0)\), \(C=(-2,0)\), the area is 12 and the centroid is \((4/3, 1)\), so the integral equals \(12 \times (1 - 4/3) = -4\). Geometry alone therefore does not force profits to balance. A system-wide balance has to come from an economic assumption, such as no-arbitrage, which constrains the profit density itself. This distinction matters for the empirical section: if a market is efficient in the no-arbitrage sense, persistent price differences between segments should reflect costs and risks (transport, quality, financing) and should not yield riskless profit.

Market Space Dynamics in Commodity Trading

Consider a simplified commodity market represented as a non-equilateral triangle, where the x-axis represents price and the y-axis represents quantity traded. The triangular market space is bounded by the price limits and the maximum quantity that can be traded.

Figure 2: \(\triangle ACD\) represents Market A, and \(\triangle ADB\) represents Market B

The area of \(\triangle ADB\), with \(A=(0,3)\), \(D=(0,0)\), and \(B=(6,0)\), is

\[\begin{equation} \text{Area}(\triangle ADB) = \frac{1}{2}\times \text{base}\times \text{height} = \frac{1}{2}\times6\times3=9 \end{equation}\]

The same value follows from integration. The upper edge \(AB\) runs from \((0,3)\) to \((6,0)\), so its slope is \(-\tfrac{1}{2}\) and \(y_{\text{top}}(x) = -\tfrac{1}{2}x + 3\):

\[\begin{equation} \int_{0}^{6} \left(-\frac{1}{2}x+3\right) dx = \left[-\frac{x^2}{4} + 3x\right]_{0}^{6} = -9 + 18 = 9 \end{equation}\]

For Market A, the edge \(CA\) runs from \((-2,0)\) to \((0,3)\), so \(y_{\text{top}}(x) = \tfrac{3}{2}x + 3\):

\[\begin{equation} \text{Area}(\triangle ACD) = \int_{-2}^{0} \left(\frac{3}{2}x+3\right) dx = \left[\frac{3x^2}{4} + 3x\right]_{-2}^{0} = 0 - (3 - 6) = 3 \end{equation}\]

The total market space is \(3 + 9 = 12\), matching \(\tfrac{1}{2} \times 8 \times 3\) for base \(CB\). Under a constant profit density, Market A earns \(3/12 = 25\%\) of total profit and Market B earns \(75\%\). This is the quantity \(H_1\) concerns: if Market B corresponds to the larger firm, the model predicts a 3:1 profit split driven entirely by the geometry of market access.

Practical Implications

Profit Distribution

In the illustration above, profit shares follow directly from area shares. Moving the dividing point \(D\) along \(BC\) changes the split continuously, which gives a simple way to describe how changes in market access (a new pipeline, an exchange listing, a regulatory change) would redistribute profit between segments. With a non-constant density \(p(x,y)\), the same calculation shows where in price-quantity space the profit is concentrated.

Arbitrage Opportunities

Within this framework, an arbitrage opportunity is a region where realized prices depart from the equilibrium relationship implied by the market space. If observed prices at a given quantity sit persistently above or below that relationship in one segment relative to another, the gap is a candidate opportunity. Whether it is a true arbitrage depends on whether the gap exceeds the cost of moving the commodity or position between segments.

Market Efficiency

Under efficiency, those gaps should be no larger than transaction, transport, and risk costs, so the expected excess profit across segments is zero. Section 4 shows this balance is an economic restriction on \(p(x,y)\) and does not follow from the triangle’s geometry. The empirical question is therefore whether observed differences between segments exceed what costs and risks can explain.

Literature Review

The relationship between market structure and arbitrage opportunities has a long history in financial and agricultural economics. Much of the arbitrage literature focuses on price discrepancies; geometric and spatial approaches add structure to where those discrepancies arise and who captures them.

Geometric Arbitrage Theory

Farinelli and Takada (2021) develop Geometric Arbitrage Theory (GAT), which restates classical stochastic finance in the language of differential geometry. Markets are modeled as principal fibre bundles, and the curvature of the bundle’s connection measures instantaneous arbitrage capability. Arbitrage conditions such as No-Free-Lunch-with-Vanishing-Risk (NFLVR) and No-Unbounded-Profit-with-Bounded-Risk (NUPBR) are characterized in geometric terms, and the zero eigenspace of the connection Laplacian parameterizes the risk-neutral measures equivalent to the statistical one. This extends asset bubble theory to markets that do not satisfy NFLVR. The present paper borrows GAT’s motivation, that market geometry carries information about arbitrage, while working with far simpler planar geometry.

Spatial Price Equilibrium and Limits of Arbitrage

Samuelson (1952) showed that equilibrium prices across spatially separated markets can be found by solving a linear programming problem, with price differences between connected markets equal to transport costs. This spatial price equilibrium idea is the natural benchmark for the triangle model: segments that are geometrically separated should sustain price gaps up to the cost of crossing between them. Shleifer and Vishny (1997) explain why gaps can persist even beyond those costs, since real arbitrage requires capital and carries risk, and specialized arbitrageurs with limited capital may be unable to close mispricings fully. Both ideas bear on the hypothesis: the size and access of a firm determine how much of a cross-segment gap it can actually capture.

The Brent-WTI Spread

The crude oil case connects to a substantial literature on benchmark price differentials. Fattouh (2010) studies the dynamics of crude oil price differentials and finds that differentials between crudes of different quality are generally stationary, consistent with linked markets. Büyükşahin et al. (2013) examine the widening of the WTI-Brent spread after 2010 and relate it to physical market conditions, including inventory build-ups at Cushing, alongside the role of financial traders. Both studies indicate that a nonzero Brent-WTI spread is a normal feature of the market, which shapes how the results below should be read.

Methods

Daily closing prices and volumes for the front-month NYMEX WTI (CL=F) and ICE Brent (BZ=F) continuous futures were downloaded from Yahoo Finance for January 2022 through December 2023. Series were set to a business-day frequency, missing values were forward filled, and zero-volume days were dropped.

WTI and Brent volumes differ in scale, and in the raw data their volume ranges overlap on only a handful of days. Each contract’s daily volume was therefore converted to its own percentile rank \(v \in (0, 1]\). For each contract, a price-volume curve was built by sorting observations by volume percentile (averaging prices for tied ranks) and interpolating linearly between them. The area under each curve was computed exactly over the volume-percentile range \([v_{\min}, v_{\max}]\) shared by both contracts, then divided by the width of that range:

\[\begin{equation} \bar{P} = \frac{1}{v_{\max} - v_{\min}} \int_{v_{\min}}^{v_{\max}} f(v)\,dv \end{equation}\]

The normalized area \(\bar{P}\) is the average height of the price-volume curve, expressed in dollars per barrel. Because volume percentiles are evenly spaced, each trading day carries equal weight, and \(\bar{P}\) is close to the contract’s mean price over the sample. The area representation adds a view of how price varies across low- and high-activity days (Figure 3), while the comparison of \(\bar{P}\) across contracts is in effect a comparison of average price levels.

To extend the comparison forward, a non-seasonal ARIMA model was fit to each closing-price series using a stepwise search over orders up to \((3, d, 3)\), selected by AIC. A seasonal component was not used, since business-day data give no basis for a fixed seasonal period. Each model produced a 60-business-day forecast. Volume is not forecast, so the forecasted comparison uses the mean forecast price for each contract.

Results

Table 1 summarizes the comparison. WTI (CL=F) sits below Brent (BZ=F) on both measures. Across all 518 trading days, Brent averaged $4.68 per barrel above WTI, and the gap stood at $5.39 at the end of 2023.

Table 1: Normalized price-volume areas and mean 60-day forecasts (USD per barrel)

Measure WTI (CL=F) Brent (BZ=F) Brent minus WTI
Normalized area, Jan 2022 to Dec 2023 85.95 90.63 4.68
Mean 60-day ARIMA forecast 71.65 77.04 5.39


Figure 3 shows mean price within 20 equal-width volume-percentile bins for each contract, with dashed lines at the normalized areas reported in Table 1. Brent sits above WTI in all but a few bins, and neither contract shows a consistent relationship between price and relative trading activity, so the area comparison mainly reflects the level difference between the two benchmarks. Figure 4 shows closing prices from January 2022 to January 2024.

Figure 3: Mean Price by Volume Percentile for NYMEX WTI and ICE Brent (January 2022 to January 2024)

Figure 4: NYMEX WTI and ICE Brent Crude Oil Closing Prices Over Time

The stepwise search selected ARIMA(0,1,0) for both WTI (AIC = 2,373.36) and Brent (AIC = 2,382.71). This is a random walk, so the best forecast of each price is its last observed close, and the 60-day forecasts in Figure 5 are flat at $71.65 for WTI and $77.04 for Brent. The forecast spread of $5.39 is therefore the Brent-WTI spread at the end of 2023. The random-walk result is consistent with weak-form efficiency in both futures markets: past prices alone carried no usable information about the direction of the next move.

Figure 5: NYMEX WTI and ICE Brent Crude Oil 60-Day ARIMA Forecast

The difference between the two normalized areas is the average Brent-WTI differential across comparable levels of relative trading activity. A long WTI, short Brent position based on this ordering is a spread trade. WTI is a lighter, sweeter crude priced for delivery at Cushing, Oklahoma, while Brent is a waterborne North Sea benchmark, so the spread compensates for quality, location, and transport differences and moves with conditions at both delivery points. Profiting from it requires the spread to narrow, and the position carries basis risk. In the language of the framework, the two contracts occupy different segments of the market space, and the differential is the price of crossing between them. A test of \(H_1\) would ask whether firms with greater access to both segments (for example, integrated producers or traders with shipping capacity) capture a larger share of that differential than smaller firms.

Limitations

Several limitations apply. The triangle model simplifies market dynamics, and the assumption that market space is well represented by a non-equilateral triangle may not hold. The hypothesis concerns unequal-sized firms, but this paper uses only exchange-level price and volume data and does not measure firm size or firm-level profits, so \(H_0\) and \(H_1\) are not formally tested here.

The empirical illustration has its own constraints. Continuous front-month futures series contain roll effects in both price and volume. Converting volume to percentiles makes the two contracts comparable but discards information about absolute trading activity, so the analysis cannot speak to how the size of a market affects pricing. The price-volume curve treats volume as an ordering variable and ignores time, so it does not capture how the price-volume relationship shifts over the sample. The ARIMA forecasts reduce to random walks and were not evaluated out of sample, so they serve only as a forward extension of the end-of-sample spread. Finally, the comparison ignores transaction, financing, and transport costs, which determine whether any observed differential is exploitable.

Conclusion

This paper represents market space as a non-equilateral triangle and shows that, under a constant profit density, each segment’s profit share equals its area share. The worked example yields a 3:1 split from geometry alone, and the framework makes clear that system-wide profit balance must come from an economic assumption such as no-arbitrage, which constrains the profit density beyond what the geometry provides.

The crude oil illustration recovers the Brent-WTI differential, which the literature attributes to quality, location, and physical market conditions. The natural next step is a formal test of \(H_1\) using firm-level data, for example comparing how integrated and non-integrated firms capture cross-market differentials. Further extensions include estimating non-constant profit densities from transaction data, accounting for transport and financing costs, and evaluating forecasts out of sample.


References

Büyükşahin, B., Lee, T. K., Moser, J. T., & Robe, M. A. (2013). Physical markets, paper markets and the WTI-Brent spread. The Energy Journal, 34(3), 129–151.

Farinelli, S., & Takada, H. (2021). Can you hear the shape of a market? Geometric arbitrage and spectral theory. Axioms, 10(4), 242. https://doi.org/10.3390/axioms10040242

Fattouh, B. (2010). The dynamics of crude oil price differentials. Energy Economics, 32(2), 334–342.

Samuelson, P. A. (1952). Spatial price equilibrium and linear programming. The American Economic Review, 42(3), 283–303.

Shleifer, A., & Vishny, R. W. (1997). The limits of arbitrage. The Journal of Finance, 52(1), 35–55.



Appendix: Market Space Dynamics Algorithm

Algorithm 1 Market Space Dynamics Analysis
 1:  Input: Historical price and volume data for each security
 2:  Output: Normalized areas, forecasts, and price-differential summary

 3:  Step 1: Data Collection and Cleaning
 4:      Download daily close and volume; set business-day frequency
 5:      Forward fill missing values; drop zero-volume days
 5a:     Convert each security's volume to its own percentile rank

 6:  Step 2: Build Price-Volume Curves
 7:      for each security do
 8:          Sort observations by volume percentile; average prices at tied ranks
 9:          fsecurity(v) = linear interpolation of price on volume percentile
 10:     end for

 11:  Step 3: Normalized Areas
 12:      [vmin, vmax] = volume-percentile range shared by every security
 13:      for each security do
 14:          area = ∫vminvmax fsecurity(v) dv   (exact trapezoid over knots)
 15:          P̄security = area / (vmax − vmin)
 16:      end for

 17:  Step 4: Forecast
 18:      for each security do
 19:          Fit non-seasonal ARIMA by stepwise AIC search
 20:          Forecast h business days; record mean forecast price
 21:      end for

 22:  Step 5: Compare
 23:      Identify lowest and highest P̄ and the spread between them
 24:      Repeat using mean forecast prices

 25:  Step 6: Plot binned price by volume percentile, closing prices, and forecasts
 26:  End Algorithm

Appendix: MarketSpaceDynamics Class Implementation in Python

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""MarketSpaceDynamics: price-volume area comparison and ARIMA forecast.

Run as a script to regenerate the figures and Table 1 values for the paper:
    python market_space_dynamics.py
"""

import numpy as np
import pandas as pd
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import yfinance as yf
from pmdarima import auto_arima


class MarketSpaceDynamics:
    def __init__(self, tickers, start_date, end_date, forecast_steps=60, labels=None):
        self.tickers = tickers
        self.start_date = start_date
        self.end_date = end_date
        self.forecast_steps = forecast_steps
        self.labels = labels if labels is not None else {t: t for t in tickers}
        self.data = {}
        self.fetch_data()

    def fetch_data(self):
        for ticker in self.tickers:
            raw = yf.download(
                ticker,
                start=self.start_date,
                end=self.end_date,
                auto_adjust=False,
                progress=False,
            )
            # Recent yfinance versions return MultiIndex columns even for one ticker
            if isinstance(raw.columns, pd.MultiIndex):
                raw.columns = raw.columns.get_level_values(0)
            df = raw[["Close", "Volume"]].asfreq("B").ffill()
            # Drop zero-volume rows (holidays, roll artifacts)
            df = df[df["Volume"] > 0].rename(columns={"Close": "Price"})
            # Volume scales differ across exchanges, so each contract's volume
            # is expressed as its own percentile rank (0 to 1). Both curves then
            # share the same axis and every trading day contributes.
            df["VolPct"] = df["Volume"].rank(pct=True)
            self.data[ticker] = df

    def volume_range(self):
        """Full span of volume percentile across all tickers (used for plotting)."""
        lo = min(self.data[t]["VolPct"].min() for t in self.tickers)
        hi = max(self.data[t]["VolPct"].max() for t in self.tickers)
        return float(lo), float(hi)

    def overlap_range(self):
        """Volume range observed for every ticker (used for areas).

        Outside a ticker's own observed range, np.interp holds the end value
        flat, so integrating over the full span would let one boundary price
        dominate the area. Restricting to the overlap avoids that.
        """
        lo = max(self.data[t]["VolPct"].min() for t in self.tickers)
        hi = min(self.data[t]["VolPct"].max() for t in self.tickers)
        if hi <= lo:
            raise ValueError("Volume ranges do not overlap; areas are not comparable.")
        return float(lo), float(hi)

    @staticmethod
    def price_volume_curve(df):
        """Mean price at each volume percentile, sorted by percentile.

        np.interp requires increasing x values, so the series is sorted by
        volume percentile and tied percentiles are averaged.
        """
        curve = df.groupby("VolPct")["Price"].mean().sort_index()
        return curve.index.to_numpy(dtype=float), curve.to_numpy(dtype=float)

    def price_volume_function(self, df):
        xp, fp = self.price_volume_curve(df)
        return lambda x: np.interp(x, xp, fp)

    def calculate_area(self, df, lower_limit, upper_limit):
        """Exact integral of the piecewise-linear price-volume curve.

        The trapezoid rule is exact for a piecewise-linear function when it is
        evaluated at every knot, so no numerical quadrature is needed.
        """
        xp, fp = self.price_volume_curve(df)
        inside = xp[(xp > lower_limit) & (xp < upper_limit)]
        x = np.concatenate([[lower_limit], inside, [upper_limit]])
        y = np.interp(x, xp, fp)
        return float(np.sum((x[1:] - x[:-1]) * (y[1:] + y[:-1]) / 2))

    def normalized_areas(self):
        """Area under each price-volume curve divided by the volume range.

        This equals the average height of the curve over the overlapping
        volume range, so the result is in price units.
        """
        lo, hi = self.overlap_range()
        return {
            t: self.calculate_area(self.data[t], lo, hi)
            / (hi - lo)
            for t in self.tickers
        }

    def forecast_prices(self, df, steps):
        # Pass plain values: dropping zero-volume days leaves gaps in the
        # date index, which statsmodels cannot forecast from.
        model = auto_arima(
            df["Price"].to_numpy(dtype=float),
            start_p=1,
            start_q=1,
            max_p=3,
            max_q=3,
            seasonal=False,  # no justified 12-period season in business-day data
            stepwise=True,
            suppress_warnings=True,
            trace=False,
        )
        forecast = np.asarray(model.predict(n_periods=steps))
        index = pd.bdate_range(df.index[-1] + pd.Timedelta(days=1), periods=steps)
        return pd.DataFrame({"Forecast": forecast}, index=index), model

    @staticmethod
    def compare(values):
        low = min(values, key=values.get)
        high = max(values, key=values.get)
        return {
            "low": (low, values[low]),
            "high": (high, values[high]),
            "spread": values[high] - values[low],
        }

    # ---------- plots ----------
    def plot_price_volume_curves(self, path=None, n_bins=20):
        """Binned price-volume curves with each contract's normalized area.

        Daily points are too noisy to read, so prices are averaged within
        equal-width volume-percentile bins. Dashed lines mark the normalized
        areas used in Table 1.
        """
        areas = self.normalized_areas()
        edges = np.linspace(0, 1, n_bins + 1)
        mids = (edges[:-1] + edges[1:]) / 2
        plt.figure(figsize=(10, 6))
        for i, t in enumerate(self.tickers):
            df = self.data[t]
            binned = df.groupby(pd.cut(df["VolPct"], edges, include_lowest=True),
                                observed=False)["Price"].mean().to_numpy()
            color = f"C{i}"
            plt.plot(mids, binned, marker="o", color=color, label=self.labels[t])
            plt.axhline(areas[t], color=color, linestyle="--", linewidth=1,
                        label=f"{self.labels[t]} normalized area ({areas[t]:.2f})")
        plt.xlabel("Volume percentile")
        plt.ylabel("Mean price (USD)")
        plt.title("Price by Volume Percentile")
        plt.legend()
        self._finish(path)

    def plot_closing_prices(self, path=None):
        plt.figure(figsize=(10, 6))
        for t in self.tickers:
            plt.plot(self.data[t].index, self.data[t]["Price"], label=self.labels[t])
        plt.xlabel("Date")
        plt.ylabel("Price (USD)")
        plt.title("Closing Prices Over Time")
        plt.legend()
        self._finish(path)

    def plot_forecasts(self, forecasts, path=None):
        plt.figure(figsize=(10, 6))
        for i, t in enumerate(self.tickers):
            color = f"C{i}"  # same color per contract as Figure 4
            plt.plot(self.data[t].index, self.data[t]["Price"], color=color,
                     label=f"{self.labels[t]} Historical")
            plt.plot(forecasts[t].index, forecasts[t]["Forecast"], color=color,
                     linestyle="--", label=f"{self.labels[t]} Forecast")
        plt.xlabel("Date")
        plt.ylabel("Price (USD)")
        plt.title(f"{self.forecast_steps}-Day ARIMA Forecast")
        plt.legend()
        self._finish(path)

    @staticmethod
    def _finish(path):
        plt.tight_layout()
        if path:
            plt.savefig(path)
            plt.close()
        else:
            plt.show()

    # ---------- driver ----------
    def run_analysis(self, save_figures=False):
        current = self.compare(self.normalized_areas())

        forecasts, models = {}, {}
        for t in self.tickers:
            forecasts[t], models[t] = self.forecast_prices(self.data[t], self.forecast_steps)
        forecasted = self.compare({t: float(forecasts[t]["Forecast"].mean())
                                   for t in self.tickers})

        if save_figures:
            self.plot_price_volume_curves("price_volume_curves.svg")
            self.plot_closing_prices("closing_prices.svg")
            self.plot_forecasts(forecasts, "market_analysis_forecast.svg")

        for name, res in [("Current (normalized area)", current),
                          ("Forecasted (mean forecast price)", forecasted)]:
            print(f"{name}:")
            print(f"  Lower: {res['low'][0]} = {res['low'][1]:.2f}")
            print(f"  Higher: {res['high'][0]} = {res['high'][1]:.2f}")
            print(f"  Spread: {res['spread']:.2f}")
        for t, m in models.items():
            print(f"{t}: ARIMA{m.order}, AIC = {m.aic():.2f}")

        lo, hi = self.overlap_range()
        print(f"Overlapping volume-percentile range: {lo:.3f} to {hi:.3f}")
        for t in self.tickers:
            v = self.data[t]["VolPct"]
            n_in = int(((v >= lo) & (v <= hi)).sum())
            print(f"  {t}: {n_in} of {len(v)} days inside the overlap")

        return current, forecasted, forecasts, models


if __name__ == "__main__":
    labels = {"CL=F": "NYMEX WTI Crude Oil", "BZ=F": "ICE Brent Crude Oil"}
    analysis = MarketSpaceDynamics(
        ["CL=F", "BZ=F"], "2022-01-01", "2024-01-01",
        forecast_steps=60, labels=labels,
    )
    analysis.run_analysis(save_figures=True)
Previous
Next