Skip to main content

slippage

An optimal execution and transaction cost analysis engine.

The library answers two questions that sit either side of a trade:

  • After the fact — what did this order actually cost, and where did the cost come from: the delay before it reached the market, the impact of trading it, or the part that never got done?
  • Before the fact — given a market impact model and a tolerance for risk, how should the remaining quantity be spread over the trading horizon?

Under both sits a validated numerical core: implementation shortfall that is checked against its own direct formula on every call, impact models fitted with honest standard errors, the Almgren–Chriss trajectory checked against the paper's published figures, and a constrained scheduler checked against exhaustive enumeration. Each section below states what was measured and how.

See ROADMAP.md for the build order.

What is checked, and against what

Here is what that paragraph means, before the install line. The order of the rows is deliberate: the first two are checked against enumeration, not against a formula. Every figure is produced by examples/evidence.py, which runs in CI and exits non-zero if any of them moves:

Check Result
The schedule solver against exhaustive enumeration of all 1,820 schedules, 12 cases worst relative gap 1e-14, capped and uncapped
The router against exhaustive search, 250 random problems at the optimum in all 250, worst excess cost 0
A 2,000-lot grid against the Almgren-Chriss closed form every trade within one lot of the continuum solution
The same grid's objective against the continuum's never below it, the grid being a subset of the continuum
The response kernel at zero urgency, against a tent curvature 1e-14 of its peak away from the spike; 2e-02 at an urgency of 1e-6, so the shape is a result
Implementation shortfall against the same sum done by hand identical on every component, to the cent
The ex-own VWAP against a book whose other prints are known, 200 books recovers their VWAP to 1e-12 relative, where the uncorrected benchmark understates by 8% to 52%
The uniform schedule's best horizon against the Almgren-Chriss half-life, 7 risk aversions their product is sqrt(3) to within 0.50%, worst at a risk aversion of 1e-10

Why enumeration comes first. A solver checked against a closed form is checked against a rearrangement of the same mathematics, and a sign error made in both places agrees with itself. Enumeration contains no derivation at all — only the objective, evaluated at every point of a finite set — so it cannot hold the same mistake. Twelve lots over five periods is 1,820 schedules, small enough to enumerate and large enough that the optimum is not obvious; the capped cases matter most, because an unconstrained problem is one a closed form could have answered anyway.

The last row's reference belongs to another module. The horizon comes from capacity, which finds it by a golden-section search on the discrete objective and never writes sqrt(3) down; the half-life comes from execution, which solves kappa against eta_tilde. Neither can produce the identity alone, and what is left of it is the discretisation: an interval count is an integer, so between one count and the next the search varies the interval length in proportion to the horizon. The worst case sits where the horizon is just long enough for the interval bound to start raising the count, which is why the row quotes a worst case over a sweep rather than a figure at one risk aversion.

The ex-own VWAP row's reference is a construction, not a formula. Two hundred books are built from explicit prints — a set the order did and a set it did not, each with its own quantity and price — so the other traders' VWAP is known before the library is asked: it is the volume-weighted average of the prints the order had no part in. The generator never computes a participation rate or a difference of notionals; it knows the answer because it wrote the prints down. The correction recovers it to 6e-16, and over those books the uncorrected benchmark understates the cost by between 8% and 52%.

The router is checked on random problems rather than hand-picked ones, since a hand-picked routing problem is one whose answer the person picking it could see. Its default search radius is a local move limit, so the question being asked is whether a local search reaches the global optimum. On 250 problems it did, every time, with an excess cost of exactly zero.

Two rows are results, not tolerances.

The grid's objective is checked for never being below the continuum's. The lot grid is a subset of the continuum, so the best schedule on the grid cannot cost less than the best schedule in the continuum — and a programme reporting a lower objective would not be more accurate, it would be wrong. A tolerance on the signed difference would accept it. So the bound is zero on how far below it goes: a tie passes, an improvement does not. Measured, the grid comes out at 130.626543 against the continuum's 130.626537, which is the right side.

The kernel's shape is measured twice. At zero urgency the kernel of the quadratic execution problem is the Green's function of a discrete Laplacian, which means every row is linear away from its own spike — a tent. The parameter-free way to say that is that a row's second difference vanishes except at the spike, needing no fitted scale and no reference implementation: 1.2e-16 of the peak. On its own that figure would say nothing about whether the shape was derived or assumed, since a kernel that had been linearised somewhere would also be a tent. At an urgency of 1e-6 the same curvature is 1.9e-2 of the peak, so it is a shape the problem produces at one end of its range and not the other.

python examples/evidence.py     # all six, non-zero if a figure moves

Behind them: 1052 tests on Python 3.10 through 3.13, including Hypothesis-generated cap and floor combinations and infeasible ones.

Install

pip install slippage-tca
slippage sample-data --out book
slippage tca --orders book/orders.csv --fills book/fills.csv --bars book/bars.csv

Not on the package index yet. The pip install slippage-tca line above is what it will be; until the first release lands, install from source and the commands under it work unchanged:

pip install "git+https://github.com/DaniyalMlk/slippage.git"

The distribution is slippage-tca; the import is slippage. pip install slippage fetches an unrelated project that was on the index first. The Python package keeps the short name because renaming it would have broken every existing import to settle a registry collision, so:

import slippage  # installed from slippage-tca

To work on the library instead:

pip install -e ".[dev]"
python -m pytest

Python 3.10 or later. The only runtime dependency is NumPy.

Command line

Installing the package provides a slippage command. sample-data writes a synthetic book, so every step below runs without real data:

slippage sample-data --out book
slippage tca --orders book/orders.csv --fills book/fills.csv --bars book/bars.csv \
    --fees-per-share 0.0005 --group-by side
60 orders, paper notional 205,208,433, shortfall -62,784 (-3.06 bps)

             orders        delay      trading  opportunity   commission         fees      total
-----------------------------------------------------------------------------------------------
buy              32         1.38       -12.29         0.14         0.19         0.05     -10.53
sell             28         5.71         7.51        -0.96         0.26         0.06      12.59
-----------------------------------------------------------------------------------------------
all              60         2.78        -5.89        -0.21         0.21         0.05      -3.06
(basis points of paper notional)

outliers at a modified z-score of 3.5:
  ORD0046    SYM06    sell   -147.34 bps   z = -5.1
  ...
slippage schedule --quantity 1000000 --horizon 5 --periods 5 --volatility 0.95 \
    --gamma 2.5e-7 --eta 2.5e-6 --epsilon 0.0625 --risk-aversion 1e-6 \
    --max-trade 300000 --lot-size 1000
method: dynamic programme, lot 1000
period     start         trade     remaining
     0         0       300,000       700,000
     1         1       300,000       400,000
     2         2       196,000       204,000
     3         3       118,000        86,000
     4         4        86,000             0

expected cost 756,873, standard deviation 794,266

schedule uses the closed form when impact is linear and nothing constrains it, and the dynamic programme otherwise. Here the cap slows the first two days and so lowers expected cost (from 911,227 unconstrained) while raising risk. The objective the trader asked to minimise, E + λV, rises from 1,275,356 to 1,387,732, which is what a binding constraint must do. frontier prints the efficient frontier. Every subcommand takes --json, and bad input exits with status 2 and a message naming the file and line.

slippage markouts --orders book/orders.csv --fills book/fills.csv \
    --bars book/bars.csv --benchmark index.csv
Mark-outs over 60 orders, market-adjusted at beta 1

  realised cost against arrival       3.98 bps
  impact at completion               10.52 bps +/- 0.53

    horizon  orders     persisted   reverted  of impact
        1m      60     9.34 +/-0.53       1.18      11.2%
        5m      60     6.80 +/-0.57       3.72      35.4%
       15m      60     4.49 +/-0.71       6.03      57.3%
       30m      60     4.36 +/-0.89       6.17      58.6%
       60m      60     3.46 +/-1.08       7.07      67.1%

  half-life 279s, heading for 3.84 bps of permanent impact (r-squared 0.985)

Scoring an execution

from datetime import datetime, timedelta
from slippage import Bar, BarSeries, Benchmark, Fill, Order, Side, score_order

t0 = datetime(2026, 3, 2, 9, 30)
bars = BarSeries(
    Bar(
        t0 + timedelta(minutes=i),
        open=50 + 0.02 * i,
        high=50.05 + 0.02 * i,
        low=49.97 + 0.02 * i,
        close=50.02 + 0.02 * i,
        volume=20_000,
    )
    for i in range(30)
)
order = Order(
    symbol="ACME",
    side=Side.BUY,
    quantity=5_000,
    decision_time=t0,
    arrival_time=t0 + timedelta(minutes=2),
    fills=(
        Fill(t0 + timedelta(minutes=3), 2_000, 50.09),
        Fill(t0 + timedelta(minutes=7), 3_000, 50.16),
    ),
)
for b in (Benchmark.ARRIVAL, Benchmark.INTERVAL_VWAP, Benchmark.CLOSE):
    s = score_order(order, bars, b)
    print(f"{b.value:<14} {s.benchmark_price:8.4f}  {s.cost_bps:+6.2f} bps")
arrival         50.0400  +18.39 bps
interval_vwap   50.1033   +5.72 bps
close           50.1600   -5.58 bps

The same fills cost eighteen basis points against arrival and saved five and a half against the close. Neither number is wrong; they answer different questions. Arrival measures what trading cost relative to doing nothing; interval VWAP measures whether the order kept pace with the market while it traded; the close rewards any buy that finished before a rally. Choosing the benchmark is choosing the question.

Whose volume is in the benchmark

Choosing the window is the convention that gets argued about. Choosing whose prints are in the benchmark is the one that gets missed, and it is the larger of the two.

Exchange volume includes the order's own fills, so a VWAP built from those bars is a benchmark the order helped set. Over any window it decomposes as

vwap_market = p * own_average + (1 - p) * vwap_ex_own

with p the order's share of the window's volume, and therefore

own_average - vwap_market = (1 - p) * (own_average - vwap_ex_own)

So the cost reported against the market VWAP is (1 - p) times the cost against a benchmark the order had no part in. At 28% participation the number is 28% too small. At 100% — a block crossed while nothing else traded — the market VWAP is the order's average price and the reported cost is exactly zero whatever was paid, which the library now refuses rather than reports.

from slippage import own_share

share = own_share(order, bars)
share.rate  # p, over the benchmark's own window
share.market_vwap  # the benchmark as recorded
share.ex_own_vwap  # what the rest of the market paid
score_order(order, bars, Benchmark.INTERVAL_VWAP_EX_OWN)

slippage tca reports both, because the uncorrected figure is the one that gets read. On the shipped sample book:

VWAP cost over 59 filled order(s): +8.91 bps against the bars as recorded
and +11.67 against a benchmark these orders had no part in setting. Exchange volume
includes their own prints, so order by order the first is (1 - p) times the second, with
p the share of volume taken: a median of 9.7% and a mean of 14.4%.
The book moves by a factor of 0.76 rather than 0.86, because the orders
that took the most volume are the ones that cost the most.

That last line is there because the first draft stopped before it and invited arithmetic that does not hold. Participation and cost correlate at 0.54 on this book, so the average moves further than either rate implies, and a reader left to multiply the mean rate by the honest figure would get the wrong number.

What the correction assumes, since only half of it is certain. The volume a bar records does include the order's shares, so subtracting them from the denominator is right. The notional does not exist in OHLCV data and is estimated here from the bar's typical price, so the numerator's correction is as good as that estimate and no better. The identity above then holds exactly as arithmetic — it is the definition rearranged — and the size of what it corrects is a fact about the data.

It also has to use the benchmark's own window, which is not the one the participation rate uses. participation_rate starts at the bar containing arrival, deliberately, so the volume the order traded against in its first minutes is not dropped; order_window starts at arrival. With a mid-bar arrival the two rates differ — 0.200 against 0.278 on the fixture in the tests — and only the second satisfies the decomposition. Which window a VWAP benchmark should use is a separate question and a convention: changing it would move every existing VWAP number in the library, so the discrepancy is reported rather than quietly resolved. Every order in the synthetic book arrives on a bar boundary, which is why it never showed.

Implementation shortfall

A single benchmark says whether an execution was good. Implementation shortfall says where the cost came from, by comparing the real portfolio with a paper one that bought everything instantly at the decision price.

examples/shortfall_worked_example.py buys 10,000 shares decided at 50.00, arriving at 50.10, filling 7,000 and cancelling the rest at 50.50:

component          by hand     library      bps
delay             1,000.00    1,000.00    20.00
trading           1,100.00    1,100.00    22.00
opportunity       1,200.00    1,200.00    24.00
commission           70.00       70.00     1.40
fees                  5.00        5.00     0.10
total             3,375.00    3,375.00    67.50
  • Delay is the market moving before the order reached it. It is a process cost, owned by whoever routes orders to the desk rather than by the trader.
  • Trading is execution against arrival. Given the half-spread it splits into the price of immediacy and the remainder, which is impact and timing.
  • Opportunity is the move on shares that never traded. A trader who cuts an order short to save trading cost moves cost here rather than removing it.
  • Explicit costs are kept apart so that a cheap commission cannot mask an expensive execution.

Every figure is in basis points of the paper notional, so the components add up in bps exactly as they do in currency. On every call the components are checked against the direct Perold formula computed by a separate route; a mismatch beyond rounding raises rather than returning a number.

Where the unexecuted shares' delay belongs is a convention. The default order basis charges delay on the whole target, since all of it sat idle; the executed basis charges it only on shares that traded. Both split the same total, which the test suite checks on randomly generated orders.

Two ways in, and which to use

implementation_shortfall takes an Order, which carries fills and the times they happened. Reach for it when you have a tape.

shortfall_from_totals takes the numbers the arithmetic actually uses — side, target quantity, filled quantity, executed notional, three prices and the explicit costs — and nothing else:

from slippage import DelayBasis, Side, shortfall_from_totals

breakdown = shortfall_from_totals(
    side=Side.BUY,
    quantity=10_000,
    filled_quantity=7_000,
    executed_notional=7_000 * 50.2571428,
    decision_price=50.00,
    arrival_price=50.10,
    final_price=50.50,
    commission=70.0,
    fees=5.0,
)

Reach for it when you have totals rather than fills — a broker's TCA extract, or anything reconstructing an order from a wire format. The decomposition never reads a timestamp: the same fills spaced a minute apart and six hours apart give identical breakdowns to every digit, which the test suite asserts rather than assumes. Building an Order for it would mean inventing times the answer does not depend on, and invented values look like data to everything downstream.

implementation_shortfall is a thin wrapper over shortfall_from_totals, so there is one implementation of the identity and one place the Perold cross-check lives. A property test drives both over randomly generated orders and compares them field for field.

Reporting across a book

build_report decomposes every order and aggregates by any key. Every aggregate is a ratio of sums: a group's cost in basis points is its total cost over its total paper notional. An average of per-order basis points would give a hundred-share order the same weight as a million-share one, and its components would stop adding up to its total once orders of different sizes mix. With ratios of sums the components add up at the order, group and book level, and the tests check all three.

Outliers are screened by the modified z-score, 0.6745 (x − median) / MAD, with Iglewicz and Hoaglin's threshold of 3.5. An ordinary z-score lets large outliers hide: they inflate the standard deviation they are measured against. In the test suite, two values fifty times the typical size score under 3 on a classical z-score and over 3.5 on the modified one. An order whose fills are made 3% worse is the first one the report flags.

compare_to_model sets each order's realised trading cost beside what a fitted square-root law expected. The comparison is per executed share, so an order that was cut short is judged on what it traded.

Which part of the impact came back

A cost against arrival contains both impact terms and the schedule only responds to one of them. slippage.reversion marks the order out at a set of horizons after it completes and splits the move over the order window into the part that persisted and the part that reverted:

from slippage import reversion_profile, permanent_moves_from_orders, fit_permanent

profile = reversion_profile(orders, bars, benchmark=index)
profile.mean_impact_bps  # 10.52 +/- 0.53
profile.at(timedelta(minutes=60)).mean_permanent_bps
profile.reverted_fraction  # 0.67 of the impact came back
profile.decay().half_life  # 279 seconds
profile.decay().asymptote_bps  # 3.84 bps that did not

# and the pair fit_permanent always wanted and nothing produced
fit_permanent(
    *permanent_moves_from_orders(
        orders,
        bars,
        horizon=timedelta(minutes=45),
        benchmark=index,
    )
)

That last call is the gap this closes. fit_permanent documents its second argument as "the price changes that persisted after each order completed, typically measured well after the last fill" and nothing measured them: a caller had to walk the bars, choose a horizon and sign the move for the side, and getting any of the three wrong gives a coefficient with the right units and the wrong value.

The market is larger than the thing being measured

Over the eighty minutes from arrival to an hour past completion, a stock's own move accumulates about twenty-seven basis points against perhaps ten of impact. A mark-out with the market left in is mostly a report about the market, and examples/mark_outs.py measures exactly how much that costs. Twenty-five books of 120 orders, built with 4 bps of permanent impact and 6 of temporary decaying with a 208-second half-life:

figure truth market left in market taken out
impact at completion, bps 10.00 9.99 (off 0.80) 10.02 (off 0.35)
reverted fraction by an hour 0.600 0.600 (off 0.098) 0.609 (off 0.046)
fitted half-life, seconds 208 244 (off 90) 222 (off 34)
fitted permanent impact, bps 4.00 3.68 (off 0.88) 3.90 (off 0.48)
standard error at an hour, bps — 2.35 0.82
fit_permanent t-statistic — 1.74, below 2 in 16 of 25 4.55, below 2 in 0 of 25

Both estimators are unbiased: the means are right either way, and a reader shown only the middle column would have no reason to think anything was wrong. What the adjustment buys is precision, roughly tripled on every figure.

The last row is what decides it. fit_permanent has to establish that permanent impact exists at all, and with the market left in it fails to do so in most of these books — a t-statistic below 2 on 120 orders, on data built with 4 basis points of permanent impact in it by construction. So the benchmark adjustment is not a refinement of this measurement. Without it the measurement does not work, and the report says so in its own output when no benchmark was passed.

The subtraction is on simple returns, which is exact when a stock moves with the benchmark one for one and first-order otherwise — a residual of 0.04 basis points against a market that wandered thirty. Using the wrong beta is the error that matters: a beta of 1 on a stock whose beta is a half leaves 8 basis points, two hundred times the rounding.

Three smaller decisions

price_at clamps to the final close past the end of a series, which is right for a price lookup and wrong here: without a flag, "we have no data that far out" and "the price stopped moving" are the same number, and the second is the more flattering. Every MarkOut carries observed, aggregation counts only the observed ones, and the count is per horizon in the result.

The decay fit is separable least squares — for a fixed time constant the curve is linear in its asymptote and amplitude, so those come out in closed form and only the time constant is searched, which leaves no starting values to get wrong. It refuses rather than reporting a half-life when the fitted amplitude is negative: an impact that grows with the horizon is what information looks like, and dressing that up as decay would hide it.

A reverted fraction is withheld, as None, when the impact it divides by is under half a basis point. At a tenth of a basis point of impact, a tenth of a basis point of noise is a reverted fraction of one.

synthetic.decaying_book exists because synthetic_book deliberately keeps impact out of the prints — the right shape for calibrating a cost model against an arrival benchmark, and empty of anything a mark-out could read.

Market impact

Two levels of model, because they answer different questions.

Rate models price trading at a given speed, which is what a scheduler needs. LinearImpact is the Almgren–Chriss parameterisation — permanent g(v) = γv, temporary h(v) = ε + ηv — and PowerLawImpact replaces the temporary term with ηv^β. schedule_cost prices any discrete schedule under either, split into temporary and permanent cost; the tests check it against the hand-integrated cost of uniform schedules and against the Almgren–Chriss identity γ(X² − Σnₖ²)/2 for arbitrary ones.

Permanent impact is linear in both, and there is no way to make it otherwise. Huberman and Stanzl (2004) show that nonlinear permanent impact admits round-trip manipulation, so an optimiser given such a model would find profit in its own footprint.

The square-root law I = Yσ(Q/V)^δ prices a whole order by its size relative to daily volume. It gives the peak impact; an order worked at a constant rate pays the average of the impact path, I/(1+δ) — two thirds of the peak under the square root. Quoting the peak as the cost overstates it by half.

Calibration

Every fit returns estimates with standard errors and a list of identifiability warnings, also raised as IdentifiabilityWarning. Five hundred synthetic orders generated from Y = 0.8, δ = 0.5:

fit = fit_power_law(participation, cost, volatility)
fit.y  # 0.814 +/- 0.024
fit.delta  # 0.503 +/- 0.009
fit.to_law().expected_cost_bps(1e5, 1e7, 0.02)  # 10.7 bps for 1% of ADV at 2% vol

Forty noisy orders that all sit between 1% and 1.5% of daily volume:

delta = 1.50 +/- 0.84
 - order sizes span only a 1.47x range; the exponent is not identified below a 4x range — fix delta instead
 - the exponent 1.500 sits on the search bound (0.05, 1.5); the true optimum may lie outside it
 - exponent standard error 0.84 exceeds 0.25; the data cannot distinguish a square-root law from a linear one

The exponent is pinned down by how much order sizes vary, not by how many orders there are, and a fit that returned 1.50 without comment would hand a scheduler a number the data never supported. The thresholds behind each warning are module constants in slippage.calibration, stated so they can be argued with. The tests also check that the reported standard errors are honest: over repeated synthetic samples the 95% interval contains the true exponent between 89% and 99% of the time.

samples_from_orders turns executed Orders into calibration inputs, measuring cost against arrival so that delay does not leak into the impact coefficients. Its time unit is a required argument: a rate coefficient fitted per hour and used per day is wrong by a factor of the trading day's length, silently.

Impact that decays at a rate

slippage markouts measures how much of an order's impact came back and over what half-life. Nothing else here could produce that curve. The rate models in slippage.impact charge for the current trading speed, so their temporary impact is gone the instant the speed is zero, and gamma never decays at all. A desk that had measured a reverted fraction of 0.6 with a half-life of eleven minutes had learned something no scheduler here could use.

slippage.transient is the missing middle. Impact is a decay kernel, or propagator: each share pushes the price by G(0) and that push decays as G(lag) while the rest of the order works, towards a permanent floor G(inf). The two existing descriptions are its two limits.

$ slippage transient --quantity 100000 --horizon 8 --periods 8 \
      --gamma 1e-6 --eta 2e-5 --half-life 2.7726
kernel: ExponentialDecay
impact per share: 2.1e-05 now, 1e-06 permanent
period     start         trade     remaining
     0         0        30,055        69,945
     1         1         6,648        63,296
     2         2         6,648        56,648
     3         3         6,648        50,000
     4         4         6,648        43,352
     5         5         6,648        36,704
     6         6         6,648        30,055
     7         7        30,055             0

cost 58,463 against 62,512 at a constant rate: 6.477% saved, first slice 2.4x uniform

The cost is a quadratic form, so nothing here needs an optimiser

Slice k pays the impact already in the price from every earlier slice, plus half of its own:

cost = sum_k n_k [ sum_{j<k} n_j G(t_k - t_j) + n_k G(0) / 2 ]  =  n' K n / 2

with K_ij = G(|t_i - t_j|). The second form is the first rearranged, and the tests check them against each other by walking the price path trade by trade, because a rearrangement is the step most likely to be wrong and least likely to look it.

Having the cost as a symmetric form makes the cost-minimising schedule a linear solve: n* = X K^-1 1 / (1' K^-1 1), at a cost of X^2 / (2 * 1' K^-1 1). No starting values, no convergence to report. The shape above — a block, a constant rate, a block — is Obizhaeva and Wang's continuous-time result, and nothing in the code imposes it. It falls out of the solve, which is the most convincing form the result can take; an optimiser tuned until it drew the expected picture would be evidence of nothing. Compared against SLSQP on the same problem the closed form is 0.27% cheaper at low resilience, because the optimiser stops early.

A kernel that is not positive definite pays you to trade

If K is negative on some zero-sum direction then a round trip — buy some, sell the same back — has negative impact cost, and an optimiser handed that model finds profit in its own market impact. So it is checked rather than documented, and manipulation_round_trip returns the offending trip rather than a boolean: "your kernel is inadmissible" is much less use than the twelve numbers that break it.

Being a decreasing function of lag is not sufficient, which is the part worth knowing. A kernel that falls almost flat and then drops off a shoulder is non-negative, bounded and strictly decreasing at every lag — all checked in the test rather than asserted — and it still admits a round trip costing -2.35 over twelve slices whose largest is one share. Complete monotonicity is the property that works, because it makes the kernel a mixture of exponentials and every exponential is positive definite on its own. Both offered kernels have it.

The threshold for that check is relative, and has to be. The cost matrix restricted to zero-sum directions is singular by construction, so its smallest eigenvalue is zero up to rounding — and on a perfectly good power-law kernel that rounding comes out negative, at around -1e-16 of the matrix scale. A test against zero would report a free lunch of 1e-16 on an admissible model.

The gain from scheduling well is not monotone in resilience

Both limits give nothing, for different reasons. Infinitely resilient, the matrix is diagonal, the cost is sum n_k^2 and a constant rate is already optimal. Not resilient at all, the matrix is constant, the cost is X^2 G(0) / 2 and every schedule ties. So the saving peaks in between:

slices peak at resilience x horizon saving
4 1.62 3.57%
8 2.00 7.04%
16 2.29 9.38%
32 2.47 10.73%
64 2.57 11.46%

The saving is a function of resilience x horizon alone — eight slices over eight minutes and eight over thirty give identical numbers once that product matches, so the spacing is not a second parameter. What it does depend on is how finely the horizon is cut, because the solution wants two instantaneous blocks and a finer grid approximates them better. There is therefore no single peak to quote without saying how many slices it is over, which a first version of this table did by reading every grid at one grid's peak.

A mark-out recovers the resilience exactly and the amplitude not at all

residual_impact is the curve the model predicts after the order finishes. For an exponential kernel the horizon appears in every term as the same factor, so it comes outside the sum and the curve is exactly permanent * X + A exp(-rho h) whatever schedule produced it — checked to twelve decimal places. The decay rate is a property of the kernel alone.

The amplitude is not. A mark-out is taken from the moment the order completed, by which time everything except the last slice has been decaying. For a uniform schedule of N slices over a horizon T the measured amplitude is the kernel's transient impact times

(1/N) (1 - exp(-rho T)) / (1 - exp(-rho T/N))

which over two half-lives is 0.589 at eight slices and 0.541 as trading becomes continuous. So ExponentialDecay.from_measured_decay reads the transient part back about 40% low on a worked order, and is exact only for a single block. It says so, because the alternative is being quietly wrong in the direction that makes trading look cheaper than it is.

One function was wrong in a way that read as right

impact_path used to return one entry past the last slice and call it the impact at completion. It was one interval late, understating the impact at completion by exactly one period of decay — 16% on the grid it was checked on, which is far too small to look like a bug and far too large to ignore. It now stops at the last slice, and what happens afterwards takes the horizons it is asked about rather than implying one.

Optimal execution

optimal_trajectory solves the Almgren–Chriss problem: execute X shares over N intervals minimising E[cost] + λ·Var[cost] under linear impact. The holdings are x_j = X sinh(κ(T − t_j)) / sinh(κT), with the urgency κ solved exactly from the discrete relation 2/τ²·(cosh κτ − 1) = λσ²/η̃.

examples/almgren_chriss.py runs the paper's own example — a million shares over five days — across risk aversions:

  lambda   kappa  half-life    E[cost]         sd    day 1
       0   0.000       infd    662,500  1,040,673  200,000
   1e-07   0.195      5.14d    670,057    961,623  242,118
   1e-06   0.607      1.65d    911,227    603,431  458,044
   1e-05   1.727      0.58d  1,845,211    171,712  822,132

The paper reports κ ≈ 0.6/day, so κT ≈ 3 at λ = 10⁻⁶, which the test suite checks. The risk-neutral row is the straight line and its cost is the hand figure ½γX² + εX + η̃X²/T. Moving from λ = 10⁻⁷ to 10⁻⁶ cuts the standard deviation of cost by 37% for a 36% rise in its expectation; that exchange rate is the efficient frontier, and efficient_frontier traces it.

Two computational choices:

  • Moments are summed over the schedule, not taken from the closed form. The published expressions for E and V contain sinh(2κT) and overflow for an impatient trader long before the trajectory does. They are kept as closed_form_moments, and the tests require the two to agree to nine significant figures wherever both are finite.
  • Holdings switch to a ratio of exponentials above κT = 20, so a schedule with κT in the thousands is still finite. The tests check the two forms against the textbook ratio on both sides of the switch.

half_life_sensitivity gives the elasticity of the half-life 1/κ to each input by differentiating the discrete relation. In continuous time they are exactly −½ for risk aversion, −1 for volatility and +½ for temporary impact; with one-day intervals the example gives −0.485, −0.970 and +0.511, and the tests check both the analytic values against finite differences and their convergence to the limits as the interval shrinks.

Adapting to the liquidity you find

Every schedule above is decided before the first share trades, and under the assumptions Almgren and Chriss make that is not a shortcut — it is the answer. Impact and volatility are constant and known, nothing is revealed that the plan should depend on, and a trader who waits to see what happens cannot beat one who commits.

So the question is not whether a schedule can adapt but what has to be true before adapting is worth anything. slippage.adaptive puts liquidity on a two-state Markov chain the trader observes before trading into each period, and prices the option to react against the best schedule that still has to be fixed in advance.

$ slippage adaptive --quantity 1000000 --horizon 1 --periods 20 \
      --gamma 2.5e-7 --eta 2.5e-6 --illiquid-eta 1.25e-5 \
      --volatility 0.3 --persistence 0.8 --risk-aversion 2e-6 --draws 4000
starting regime: liquid
objective: 4.99602e+06 adapting against 6.57616e+06 fixed in advance
saving: 1.58013e+06 (24.028% of the static objective)
period      static      liquid    illiquid   liquid/static
     0    0.129346    0.097665    0.024117           0.755
     1    0.082343    0.102862    0.025714           1.249
     2    0.070741    0.108619    0.027525           1.535
   ...
    18    0.500022    0.643136    0.456509           1.286
    19    1.000000    1.000000    1.000000           1.000
simulated over 4000 paths: 4.98383e+06 +/- 2.61e+04, 0.47 standard errors from the recursion

No grid, and no approximation

The per-period cost is quadratic in the trade and the risk penalty is quadratic in the inventory, and both are homogeneous of degree two, so the value function is a_k(s) x² for one number per regime per period. Writing A = η̃(s)/τ for the cost of trading and B = λσ(s)²τ + E_s[a_{k+1}] for the cost of still holding, each step is

minimise A n² + B (x − n)²   ⇒   n* = x B/(A + B),   value = x² AB/(A + B)

and the recursion runs on 1/a, which is where the terminal constraint lands: a_N = ∞ is 1/a_N = 0, so the last period trades everything without that being a special case. The trade fraction B/(A + B) does not depend on the inventory, which is why the policy prints as a table.

With one regime it has to be Almgren-Chriss, and it is: holdings agree with optimal_trajectory to 2.3e-16 of the order size and the objective to 1.7e-16. That is the test the rest of this rests on.

The objective changes, and it has to

What is minimised here is E[cost] + λ E[Σ σ²τx²], not E[cost] + λ Var[cost]. For a deterministic schedule those are the same number — the variance of the total cost is that sum — which is why the agreement above is exact. For an adaptive schedule they are not: the inventory becomes random and the variance of the total picks up a term the sum of conditional variances does not have. A variance of a total is not a sum of per-period pieces, so no dynamic program optimises it, and the running penalty is the time-consistent substitute.

What it is worth, and three guesses it reversed

The baseline is the best deterministic schedule facing the same chain — not either regime's own schedule. A static trader who knows the chain's law uses the expected coefficients period by period, and those drift as the chain mixes: starting liquid it front-loads 129,346 shares into the first of twenty periods against about 51,000 for either pure schedule, both of which are nearly uniform. Measuring against a pure schedule would credit the policy with a gain any static trader could have taken.

Both ends of the persistence range are worth nothing, for the same reason. At a persistence of 1 the chain never moves; at 0 it strictly alternates. Both are perfectly predictable and both come out at zero to 4e-16. Variability is not uncertainty. And the peak is not in the middle: scanning at 0.001 it is 37.30% at 0.182, well onto the mean-reverting side, and that location holds between 0.171 and 0.187 across ten, twenty and fifty periods and across impact ratios of two, five and ten.

Adapting is worth most to a trader who does not care about risk — 37.64% at zero risk aversion, falling monotonically to 12.91% at 1e-03 — because a risk penalty is charged on inventory whatever the regime, so the more of the objective it accounts for the less of it the regime can move. Adaptivity is a cost device here, not a risk device.

It is liquidity worth adapting to, not volatility. Two regimes differing only in volatility by a factor of five are worth 0.115%. Two differing only in impact by the same factor are worth 24.03% — two hundred and nine times as much.

And the saving comes from waiting rather than hurrying. The middle periods look as expected: 1.906 times the static fraction when liquid, 0.589 when illiquid. But the first period trades 0.755 of the static fraction even in the liquid state, because the static schedule is front-loading into liquidity it is forecasting and the adaptive one can wait for liquidity it will observe. Which is why the illiquid start gains more at a persistence of 0.8 — 24.64% against 24.03% — with the ordering reversing by 0.9, where the liquid start gains 15.06% against 11.99%.

Fewer than three periods cannot gain anything, and that is provable rather than small: the first regime is known to the static schedule too and the last period has no decision in it, so the saving is exactly 0.0. The first non-zero gain is 1.73% at three periods.

Liquidating a basket

optimal_trajectory solves one name. Almost nothing is executed one name at a time, and a basket is not a collection of single-asset problems for two reasons that change the answer rather than refine it.

Risk on the remaining book is x'Σx, not a sum of σ_i²x_i². A hedged pair carries almost nothing while both legs are on and a great deal once one is gone, so the schedule that minimises cost plus risk keeps them on together. And impact is a matrix too: trading one name moves the others, so with a non-diagonal temporary impact matrix the schedules couple even when the returns do not.

basket_trajectory solves it exactly rather than numerically. Substituting y = E^½x for the effective temporary impact matrix E = Η − Γτ/2 turns the cost term into a plain sum of squared increments and the risk term into y'Ay with A = E^-½ΣE^-½. Diagonalise A and each eigen-coordinate is the scalar Almgren–Chriss problem with unit impact and variance μ_i: its own urgency κ_i, its own sinh, its own half-life. So a basket is liquidated along eigen-directions, and μ_i is that direction's risk per unit of impact — large means anxious and worked off first, near zero means cheap to hold and left until the end. On a correlated long/short pair those two directions are the net and the spread, which is why the pair comes off as a pair.

$ slippage basket --holdings holdings.csv --covariance cov.csv       --risk-aversion 1e-5 --compare
2 assets over 20 intervals, risk aversion 1e-05

expected cost 52,525   sd 22,782
                       direction   risk/impact   half-life
                   -0.974 -0.228     1.084e+06       0.304
                   +0.684 -0.730     3.512e+04        1.69

The reduction is the test worth having

A one-asset basket has to reproduce optimal_trajectory, and the two routes share no arithmetic: one evaluates a sinh at a closed-form κ, the other takes a matrix square root, an eigen-decomposition and two changes of basis. They agree to machine precision at every risk aversion from zero to 10⁻². An uncorrelated diagonal basket has to reproduce two independent single-asset solutions, and does.

That reduction found a real error. The permanent impact of a complete liquidation is not X'ΓX/2: each trade is not charged for its own impact, so it is that less Σ_k n_k'Γn_k/2, and the matrix the schedule is charged against is the effective one — the same correction as the scalar η̃ and the same reason for it. Computing the cost with the raw temporary matrix overstated it by two parts in a thousand, which is small enough to read as a rounding difference against the scalar solver and large enough to mean the two were not the same model.

What solving the legs together is worth, measured

A long/short pair of 100,000 shares each, correlated at 0.9, liquidated over a day in 20 intervals at a risk aversion giving κT ≈ 3. Against solving each leg as its own problem and scoring both schedules against the full matrices:

legs objective saving
identical 23.8%
short leg 4× the impact 12.1%
short leg 10× the impact 6.1%
uncorrelated, identical 0.0% exactly

The last row is the check that the saving is the correlation and not the solver.

Two things about the comparison are the opposite of the natural assumption, and both are in the command's own output rather than only here.

The joint solution is riskier moment to moment, not safer. Its peak one-interval variance is 1.19 times the independent solution's on the symmetric pair and 1.10 on the illiquid one. A hedged pair is cheap to hold, so the optimal schedule holds it longer and pays less impact. It is the cheaper schedule at the same risk aversion, not the calmer one — the preference lives in the risk aversion.

Where the independent solution is actually wrong is the shape, by an order of magnitude. It works each leg at the pace that leg's own liquidity justifies, so on a pair whose legs differ in liquidity the liquid one finishes first and the book is left outright mid-trade. With the short leg four times as expensive, the leg-by-leg schedule reaches a net exposure of 18,961 shares of a 100,000-share pair against the joint solution's 2,025 — a factor of 9.4, against an objective difference of 12.1%.

exposure_ratio measures that along hedge_direction: the riskiest direction the basket starts flat in, which is the top eigenvector of the covariance projected onto the orthogonal complement of the initial holdings. A schedule cannot be blamed for exposure it inherits, only for exposure it creates, and what it creates lives in the directions the basket had none in. On a dollar-neutral pair that derivation returns the net of the two legs; the point of deriving it rather than special-casing it is that a book with six legs and two hedges has the same question and no obvious answer.

On a symmetric pair both schedules keep the hedge exactly, so that ratio is one rounding error over another — 0.43 in a first draft of this, which reads as a finding and is noise. It is refused, with a guard relative to the basket's own size, because the residue is of order eps times that and an absolute threshold never fires.

Constrained schedules

The closed form needs linear impact and no constraints. solve_schedule solves the same mean-variance problem by backward dynamic programming over (period, remaining lots), for any impact model and with per-period participation caps, minimum trade floors and lot sizes. examples/constrained_schedule.py works a million shares under square-root impact in trading rate, with and without a 5% participation cap:

period     volume       cap      free    capped
     0  3,000,000   150,000   461,000   150,000
     1  2,000,000   100,000   215,000   100,000
     2  3,000,000   150,000   115,000   150,000
     ...
     9  2,000,000   100,000    13,000    42,000

objective E + lambda V: free 1,203,249, capped 1,694,069

The cap binds for the first six periods and the programme then eases off, rather than spreading the order evenly, because holding risk still argues for finishing early.

The solution is a policy over every state, not one path, so re-optimising after fills go off plan is a lookup: plan.trades_from(period, remaining) returns the best schedule for the rest of the horizon from wherever the order actually is. Following the policy from the planned state reproduces the rest of the plan exactly, which the tests check as time consistency.

Correctness is checked two ways that do not share code with the solver. On small problems the objective is compared with exhaustive enumeration of every possible schedule — for linear, square-root and convex impact, at three risk aversions, and on Hypothesis-generated combinations of caps and floors, including infeasible ones. On a 2,000-lot grid with slack constraints and linear impact, every trade lands within one lot of the Almgren–Chriss closed form, and the objective is never below it: the grid is a subset of the continuum, so the programme can match the closed form but not beat it. Each period is one vectorised minimisation over a states-by-trades matrix; the 2,000-lot, ten-period case solves in about a third of a second.

Volume profiles, benchmark schedules and simulation

estimate_profile builds an intraday volume curve from historical sessions. It averages each day's volume fractions instead of summing volume first: a single rebalance day with fifty times normal volume, all at the close, would otherwise put 85% of the profile in the last bucket. twap_schedule, vwap_schedule and pov_schedule generate the standard benchmarks. Lot rounding hands out leftover lots by largest remainder, so a schedule never gains or drops a lot. A POV schedule that runs out of volume reports the unfilled remainder instead of breaking its participation limit.

simulate_costs draws cost distributions under any impact model, on the Almgren–Chriss price process. examples/strategy_comparison.py sells 500,000 shares in one session under four schedules on the same price paths:

strategy       mean        sd   95th pct  unfilled
TWAP        685,096   256,910  1,106,680         0
VWAP        817,545   249,927  1,227,047         0
POV 12%     842,096   217,690  1,198,577         0
optimal     686,781   250,213  1,097,370         0

The optimal schedule costs almost the same as TWAP on average and has the lower 95th percentile, which is the trade the risk aversion asks for. VWAP looks worst on average, and the reason is the model, not VWAP. Almgren–Chriss assumes liquidity is constant through the day, so trading heavily at the open and close counts as pure cost. That concentration is the point of VWAP, because those are the hours when the market can absorb it. The row shows what the assumption costs.

The simulated cost is linear in the Gaussian price shocks. That has three consequences worth stating:

  • Simulated mean, variance, 95th percentile and expected shortfall all match their closed forms within Monte Carlo error, which is the simulator's main test.
  • Antithetic sampling makes the mean exact, since each pair of paths averages to the deterministic cost, but it does little for the tail. Over 400 repetitions of 10,000 paths it cut the standard error of the 95th percentile by 7% and of the expected shortfall by 10%. The examples turn it on because it costs nothing, not because it transforms tail estimates.
  • simulate_prices produces the unaffected price path and every fill price. The tests rebuild each path's cost from those fills and check it against the direct computation.

From fills to a schedule

examples/end_to_end.py runs the whole pipeline. It writes a 400-order synthetic book to CSV and reads it back, reports the book's shortfall, fits the square-root law to the executions, and turns the fit into a rate model for a new order worked under a 10% participation cap:

square-root law: Y = 0.512 +/- 0.088

recommended schedule for 993,000 SYM00 at a 10% participation cap:
  bucket  expected vol       cap     trade
    9:30     2,507,904   250,790   250,000  cap binds
   10:00     1,960,822   196,082   196,000  cap binds
   10:30     1,628,738   162,874   162,000  cap binds
   11:00     1,370,646   137,065   118,000
   ...
   15:30     2,439,236   243,924    12,000

The synthetic fills pay impact on the size done so far, so an order's realised cost is its peak impact times a path factor. Across this book the path factor averages about 0.92, so the 0.7 used to generate the book implies a fitted prefactor near 0.64; the fit lands within 1.5 standard errors of it. The test suite folds the path factor into the regression and recovers the generating 0.7 directly.

Rest or cross, and the factor of two

benchmarks scores a finished order; reversion measures what happened after it. placement prices the decision taken before either: cross now, or rest a bid below the mid and hope.

from slippage import Placement, evaluate

resting = Placement(
    distance=0.05,
    horizon=1.0,
    volatility=0.20,
    half_spread=0.0005,
    taker_fee=0.0001,
    maker_rebate=0.0001,
)
outcome = evaluate(resting)
outcome.fill_probability  # 0.802587 -- a *running minimum* probability
outcome.mid_if_filled  # -0.05 exactly: the limit price itself
outcome.advantage  # what resting beats crossing by, in expectation

Three results matter more than the price.

The fill probability is twice what the terminal distribution says. With no drift it is 2Φ(-δ/σ√T), because every path that ends above the barrier having touched it is matched to one that ends below. The ratio is exactly 2 — bit for bit, out to eight standard deviations, since dividing a float by half of itself is exact and a tolerance there would be covering something up. Using the terminal distribution halves the answer quietly, since the result still looks like a probability.

The reflected law's upper tail is taken as Φ(-x) and not as 1 - Φ(x), which is a cancellation once the tail is small — and here the tail is the answer. Against a sixty-digit reference the complement form is 1.5e-10 of the answer away at five standard deviations and 7.1% away at eight, while Φ(-x) is within 6e-15 at both. A limit order five standard deviations out is a real order, and the identity above only held while the tail was large.

With a drift it is not twice anything, and the drift is what makes the comparison worth making at all. terminal_probability carries it, so the ratio is 5.44 at one standard deviation of adverse drift and 1.23 at the same drift reversed. A reading that leaves the drift out of that one term is wrong in both directions and closest to right exactly where there is nothing to learn.

Conditional on filling, the expected mid at the horizon is exactly the limit price — 2bΦ(b/s) over 2Φ(b/s) is b, to twelve digits. So the adverse selection cancels the whole of the apparent saving: against the arrival mid a filled order saved δ; against the terminal mid it saved nothing. Which benchmark is in use is the entire content of "the passive fill was worth something".

And so there is no frontier. The whole driftless expected cost is (h+f)(1-p) - r·p with the distance nowhere else in it, and the standard deviation rises with the distance too — over a day at 20% volatility with a five basis point half spread, the mean goes from -0.94 to +6.00 basis points while the deviation goes from 16.4 to 126.0. Resting deeper is worse on both counts, so the answer is always the tightest price the book allows. frontier() is named for what a caller expects and will not find:

slippage placement --distance 0.0126 --horizon 0.003968 --volatility 0.2 \
  --half-spread 0.0005 --maker-rebate 0.0001 --sweep 0.01 0.5 1 2 4

What does change the answer is a drift. At half a standard deviation of it per horizon, resting one standard deviation out costs 62.3 basis points against 0.45 at the touch; at one standard deviation, 125.6 against 2.69 — a factor of 47.

Simulating a barrier is biased, and the correction fixes half of it

simulate_placement walks the path and settles the order on it, sharing no algebra with the closed forms. It is biased low, because a path checked at its own time steps misses the excursions between them: 3.60% at 250 steps, 1.89% at 1,000, 0.91% at 4,000, 0.45% at 16,000 — halving as the step count quadruples. monitoring_shift applies the Broadie-Glasserman-Kou correction and the formula then matches the discrete simulation to between 0.007% and 0.08%.

It does not fix the mean cost. The shifted formula's mean is still 21.7, 11.9, 6.1 and 1.9 standard errors away at those four step counts, because the bias in the mean lives in E[X_T 1{fill}] rather than in the probability and moving the barrier changes that term the wrong way. A test on the probability can use a modest step count and the correction; a test on the mean needs the steps.

Where to put the order, when the liquidity is random

placement prices resting against crossing at a venue. routing answers the question before that one: with several venues, how much to cross now and how much to post at each. Each venue's available liquidity is a random number of lots drawn from a distribution you supply, the passive fill there is min(posted, available), whatever is unfilled at the end is swept at a worse price, and an over-execution is unwound at a cost.

from slippage import Outflow, RoutingProblem, Venue, route

problem = RoutingProblem(
    target=9,  # lots to buy
    venues=(
        Venue("lit-a", Outflow.geometric(2.0, 8)),
        Venue("lit-b", Outflow.geometric(2.0, 8), rebate=0.10),
        # three times in ten the dark venue is empty; the rest of the time it is deep
        Venue("dark", Outflow.from_counts([30, 0, 0, 0, 0, 0, 0, 0, 70])),
    ),
    taker_cost=1.00,  # half the spread plus the taker fee, per lot
    sweep_cost=2.00,  # crossing later, after the price has moved
    unwind_cost=0.50,  # per lot of over-execution
)

routed = route(problem)
routed.allocation.posted  # (7, 7, 8)
routed.allocation.committed / 9  # 2.44 times the target
routed.cost.per_share  # 0.4544

Everything is exact arithmetic on a lattice. The distribution of the total passive fill is a full convolution rather than a normal approximation, because the two things a router pays for — the sweep of what is unfilled and the unwind of what is not — are one-sided expectations taken at a single lattice point, and neither is a function of the mean.

The same thing from the command line, which prints both factors of the marginal for each venue and checks the answer against exhaustive search:

slippage route --target 9 --venue lit-a:2:8 --venue lit-b:2:8:0.1 \
    --histogram dark:30,0,0,0,0,0,0,0,70 \
    --taker-cost 1.0 --sweep-cost 2.0 --unwind-cost 0.5 --exhaustive
target 9 lots, cross 0, committed 22 (2.44x the target)

venue      posted support    E[A]  E[fill]   rebate  reached  needed  next lot
------------------------------------------------------------------------------
lit-a           7       8    2.00     1.98    0.000    0.019   0.157   +0.0021
lit-b           7       8    2.00     1.98    0.100    0.019   0.157   +0.0002
dark            8       8    5.60     5.60    0.000    0.000   0.094   +0.0000
------------------------------------------------------------------------------
cross           0                                      1.000   0.344   +0.6409
(reached and needed are for one MORE lot there; next lot is its cost change)

expected fill 9.56 lots, shortfall 1.60, over-execution 2.16, completion 82.2%
cost: cross +0.0000  rebate -0.1981  sweep +3.2058  unwind +1.0822  total +4.0900 (+0.45444 per share)
splitting the target by average liquidity instead: +0.68873 per share, +0.23429 worse
exhaustive search over every allocation: gap +0.000e+00 (optimal)

A posted lot is worth the probability that it is both reached and needed

marginal_posted is a closed form:

reached * (-rebate - sweep_cost * needed + unwind_cost * (1 - needed))

reached is the venue's own tail at the next lot. needed is the chance that the other venues and the cross have not already covered the target, so it belongs to every venue except this one. The venue index enters nowhere else. The rebate is therefore earned on the first condition alone and the sweep is saved only on both, and the formula agrees with the difference of two exactly computed costs to 1e-12.

Which makes average liquidity the wrong ranking

One venue always has exactly three lots. Another has eight lots seven times in ten and nothing otherwise. The second has the higher mean — 5.6 against 3.0 — and the first is where the first three lots belong, because a lot there is certain to be reached.

by the margin (3, 5)   0.375 per share
by the mean   (0, 8)   0.600 per share      60% more

The everyday version of that mistake is splitting the target in proportion to average liquidity, which proportional_allocation does and compare_to_proportional prices: a median 29.9% more per share over 3,000 random instances, interquartile range 18.9% to 40.3%, already optimal on 2.5% of them.

The router is checked rather than trusted

route places lots one at a time by that marginal, then runs a local search over every move of at most radius lots. exhaustive_route enumerates every integer allocation, with the cross capped at the target and each venue capped at its outflow's support — both exact rather than generous, so it really is the optimum.

Measured against it over 9,000 random instances in three families, a one-lot neighbourhood finds 96.1% to 99.1% of the optima, a two-lot one 98.8% to 100%, and the three-lot default all of them. The local optima are as wide as the routing table: the way out of one is usually to cross a little less and post a little more at every venue at once, a move whose L1 norm is the size of the table. No fixed small neighbourhood contains it and widening the ball is not an option at (2 radius + 1) ** width vectors, so that direction is added by name.

How far past the target to post is a question about the unwind alone

unwind / sweep committed times the target
0.00 21 2.33
0.25 18 2.00
0.50 18 2.00
1.00 15 1.67
2.00 12 1.33
4.00 10 1.11

Nine lots across three venues each expecting two. A router that posts the target and no more has not chosen a conservative answer; it has answered a different question.

Fragmentation is worth something, with the liquidity held fixed

Six venues each expecting one lot cost 0.649 per share against 0.813 for one venue expecting six — a saving of 20.2%, with two and three venues at 0.761 and 0.717 in between. Independent venues diversify the fill, so the same completion probability needs less over-posting. That is the opposite of the usual complaint about fragmented markets, and it is a statement about one order rather than about a market.

A benchmark that moves, and the floor it sets

Five schedulers here — execution, scheduling, basket, transient, adaptive — minimise cost against the arrival price, a number fixed before the first share trades. Risk is in the position still held, so risk aversion front-loads.

A VWAP benchmark is a weighted average of the same prices the order trades at. It moves with the market, and the exposure is the difference between our participation and the market's. tracking is that problem.

from slippage import TrackingProblem, compare_objectives, fit_volume_covariance

uncertainty = fit_volume_covariance(profile)  # from the dispersion it measured
order = TrackingProblem(shares=1e6, profile=profile, volatility=0.004)

moments = tracking_moments(order, order.expected, uncertainty)
moments.irreducible_bps  # 8.05 -- the floor, which no schedule crosses
moments.schedule_variance  # exactly 0.0 at the volume curve

Matching the realised curve is exactly zero, path by path

With u our share of the order in each bucket and w the market's realised share, both summing to one, a driftless walk gives slippage = sigma sum_k (u_k - w_k) b_k — the arrival price cancels. So a schedule that matched the realised volume curve would have zero tracking error on every path, whatever the prices did. Nothing in an arrival-price problem behaves like this, and volume uncertainty is the only reason it cannot be done.

The volume curve is optimal, and that is a theorem

Taking variances splits the error exactly into (u - mu)' C (u - mu) + trace(C S). The second term has no schedule in it, so it is a floor. The first is a positive quadratic form minimised at u = mu, so the expected volume profile is the variance-minimising schedule — usually quoted as a rule of thumb, and it holds for any positive-definite C, so it does not depend on the price model beyond the walk having no drift. Three hundred random perturbations confirm it; the schedule term at mu is exactly 0.0.

Where in a bucket its price is read makes no difference

The choice adds a constant to every entry of C, and both terms are orthogonal to a constant: the schedule deviation sums to zero, and the covariance's rows sum to zero because the shares sum to one. The two conventions agree to twelve significant figures, and adding 1000 to every entry of C moves the floor by 1.2e-15. One fewer arbitrary parameter than the model looked like it needed.

A diagonal volume covariance overstates the floor by 56%

VolumeProfile.dispersion has been estimated since volume profiles were added and read by nothing — it is the input here. Shares live on a simplex, so their covariance cannot be diagonal, and a Dirichlet is the one-parameter family with the structure the constraint forces. Dropping the negative correlations puts the floor 56.4% too high, at every concentration. A desk told its irreducible error was 12.6 basis points when it was 8.1 would accept schedules it should refuse.

Front-loading costs 4.9 floors; ignoring the volume curve costs 0.45

The number that separates two things usually lumped together. A front-loaded arrival-price schedule tracks VWAP at 47.8 basis points against the volume curve's 8.05 — an excess of 4.93 times the floor. But TWAP, which ignores the volume curve entirely, is only 0.45 floors worse, inside what a desk can measure.

So almost all of the benefit of VWAP tracking is in not front-loading, and matching the curve exactly is the remainder. Worth knowing before building a volume forecast to chase it.

slippage vwap --quantity 1000000 --volatility 0.004 --price 50 --bucket-hours 0.5 \
  --profile 1.9 1.3 1.0 0.85 0.8 0.75 0.72 0.75 0.8 0.9 1.1 1.4 2.1 \
  --concentration 60 --eta 2e-6 --risk-aversion 0 1000 1000000

A schedule that can hold a view

execution.optimal_trajectory assumes a martingale, so the only reason to trade early is risk and the schedule can never express a forecast. slippage.alpha adds one, and the objective stays quadratic — writing the schedule as the remaining holdings makes the order constraint hold by construction, and the forecast term telescopes to sum_k x_k mu_k, because the shares outstanding after period k are exactly the ones that pay period k's drift. So the optimum is one pass of the Thomas algorithm, not a search.

slippage forecast --quantity 1000000 --horizon 1 --periods 20 \
    --volatility 0.9 --eta 2.5e-6 --gamma 2.5e-7 --risk-aversion 2e-6
1,000,000 shares over 20 periods, risk aversion 2e-06
forecast saving 400.35 (+0.0801 bps at 50)

 period       drift       optimal         blind       shift
-----------------------------------------------------------
      1     0.02000        60,862        59,588       1,274
      2     0.01900        59,137        58,061       1,075
    ...
     19     0.00200        44,366        45,056        -691
     20     0.00100        44,274        44,983        -710
-----------------------------------------------------------

closed-form value of the forecast: 400.35  (mu' G mu / 2, no solve in it)
a proportional tilt of 0.04 recovers 99.3% of it

That last line is the honest headline, and it is why the command prints it.

Three things fall out of the algebra

The response kernel is the Green's function of the Almgren-Chriss operator. The first-order conditions are a (2I - S) x + c x = -mu, whose homogeneous solutions are the sinh profiles that problem already solves for, so the inverse is closed form and is built from the same urgency kappa. The solver agrees with it to between 3e-15 and 2e-14 across risk aversions from zero up to kappa T = 5.7. The smoothing a forecast receives is not a new parameter.

It is not an exponential decay either. At a zero risk aversion the kernel is exactly triangular — a tent, the Green's function of a discrete Laplacian. A spike in the middle of twenty periods moves the holdings by 46, 93, 139, 186 ... 476 shares on the way in and symmetrically out, where a geometric decay would have given 476, 306, 196, 126.

The value of a forecast is closed form too, mu' G mu / 2, agreeing with the solved objectives to 1.4e-12. Being a quadratic form, the value is quadratic in the forecast — and therefore identical for a forecast and its negative, to twelve figures, even though the schedules they produce move in opposite directions. A signal saying "hurry" and one saying "wait" are worth the same, which is not what anybody expects of a trading signal.

The final period's drift cannot move anything, because by then there is nothing left to trade.

Three measurements that went against the guess

The optimum is far more reluctant to round-trip than expected. With a forecast reversing halfway, the unconstrained optimum first asks for a negative trade at 97.7 cents a share a period — 4.86 times a period's volatility. Impact is quadratic and the forecast linear, so buying shares back must overcome a cost rising faster than the reason to. When it does happen it is reported, because LinearImpact.temporary refuses a negative rate, correctly: a negative rate is a different trade and not a smaller cost.

The forecast is worth very little and a tuned tilt captures nearly all of it — 0.0801 basis points, 99.29% recovered. Except on a sharp signal, where the tilt recovers only 63.3% of a single-period spike's value because the optimum spreads the response and a tilt cannot. So the case for solving this exactly is sharp isolated signals, and it is weak otherwise.

A constant, and the cost that stops being one

The round-tripping branch takes its objective from the quadratic form directly, since schedule_cost will not price a negative rate, and the first version dropped that function's own constant, epsilon X + gamma X^2 / 2. On this problem that is exactly 125,000 — an objective incomparable with the blind one while looking entirely plausible.

The epsilon half then earned its own field. A fixed per-share cost is epsilon X for any schedule that trades one way, so it drops out of the optimisation, which is what makes the quadratic solve the right problem rather than an approximation to it. A round trip breaks that, because shares traded backwards and then forwards each pay it. Reported, rather than quietly wrong.

The size at which the cost consumes the reason

Everything above answers "given this size, how should it be traded?". slippage.capacity answers the question before it: how large can the order be at all?

$ slippage capacity --alpha 0.05 --volume 5e6 --volatility 0.30 \
    --gamma 2e-8 --eta 1e-7 --epsilon 0.005 \
    --participations 0.02,0.05,0.002,0.10,0.25
alpha 0.050000 per share, volume 5,000,000 per period
at a fixed horizon of 1: 409,186 shares = 0.082 periods of volume, costing 0.050000
  the continuum formula says 409,091, which is -0.000233 of it: the 1 - 1/N on the permanent term

    rate   alpha floor        capacity    periods    horizon
      2%      0.015000       3,501,751       0.70      35.02
      5%      0.030000       2,001,001       0.40       8.00
    0.2%      0.006000       4,401,000       0.88     440.10
     10%      0.055000            none          -          -
     25%      0.130000            none          -          -

The two conventions disagree about what limits capacity

A model fixes the horizon. A trader quotes participation, which fixes the rate and lets the horizon grow with the size. At a fixed horizon temporary impact per share is eta X / T, so cost per share is linear in the size. At a fixed participation rate it is eta rho V — constant in the size — so it does not bound capacity at all: it sets a floor on the alpha required, and the permanent impact sets the size limit.

The log-log slope of cost per share against size is 1.0000 at every decade with the horizon fixed, and 0.015, 0.129, 0.553, 0.914 across four decades at 5% participation: zero where the floor dominates, approaching one only once the permanent term takes over. Remove the permanent impact and the fixed-participation cost per share is 0.03000000 at a million shares, at a billion and at a trillion — the same eight digits, capacity genuinely unbounded, and raised as such rather than returned as the ceiling.

The alpha floor is the number to check first

At those parameters a 10% participation rate needs 5.50 cents a share before any size is viable and 25% needs 13.00, against an alpha of 5.00. Those rates have no capacity whatsoever, and no amount of patience about the horizon changes it, because patience is what the rate has already spent. The fixed per-share cost enters the floor too, so a half-cent commission that looks negligible against a five-cent edge is a third of the floor at 2% participation.

The horizon that maximises net alpha does not depend on the order size

For a uniform schedule every term carrying T carries the same X^2, so the optimum is sqrt(3 eta_tilde / (lambda sigma^2)) with no size in it. Measured at 500,000, two million and twenty million shares across four orders of magnitude of risk aversion: agreement to 1.3e-07 at worst. Not bit for bit, and the residual is not slack in the invariance — the objective's magnitude goes as X^2, so near a flat optimum the search's comparisons are decided by rounding and a fortyfold range of size moves the eighth significant figure. A desk that lengthens its horizon because the order is larger is responding to the wrong variable.

That optimum is sqrt(3) times the Almgren-Chriss half-life, since kappa = sqrt(lambda sigma^2 / eta_tilde) over the same ratio — measured at 1.7322, 1.7335, 1.7352, 1.7407, 1.7325, 1.7324 and 1.7322 half-lives over risk aversions from 1e-6 to 1e-13, against sqrt(3) = 1.7321.

What bounds the discretisation is the interval length, not the count

eta_tilde = eta - gamma tau / 2 has to stay positive for the discrete problem to exist at all, so the interval length tau is bounded by 2 eta / gamma whatever the horizon is. Capping the interval count instead leaves tau growing with the horizon, and a patient participation rate is a long horizon: at these parameters tau is admissible only below 10, and the ceiling the search probes at a fifth of a percent participation puts it at 12.5. So the count rises with the horizon when it has to — 2,000 intervals for a horizon of five, 4,401 for one of 440, 57,452 for one of 5,745 — and a horizon that would need more than two million of them is refused by name rather than attempted.

The fraction of 2 eta / gamma one interval may take is set by measurement, and the sqrt(3) identity is what measures it: its worst relative error over the sweep above is 2.02% at a fraction of 0.05, 0.86% at 0.02 and 0.50% at 0.01, close to a straight 45 f. Break-even capacity is far less sensitive — 3.8 basis points at a fraction of 0.5 — so the identity is what sets the bound, which is a case of the loosest-looking claim in a module being the one with the sharpest instrument behind it.

And the optimal schedule has no optimal horizon

Net alpha under the Almgren-Chriss schedule rises monotonically in the nominal horizon and saturates (10,348 at twenty, 12,139 at fifty, 12,238 at a hundred, 12,422 at two hundred) while the half-life it actually trades over barely moves (10.536 down to 10.488). The risk aversion has already chosen the effective horizon, so uniform_optimal_horizon reports an interior optimum for the uniform schedule and the module says plainly that the optimal schedule has none, rather than returning whichever point a search happened to stop at.

What a schedule built on the wrong parameters costs

The impact coefficient and the volatility behind a schedule were estimated, and they are wrong. The library had eleven ways to compute a schedule and nothing that said what being wrong costs.

The schedule sees one number. Writing out the mean-variance objective of an arbitrary schedule under linear impact:

E + lambda V = epsilon X + gamma X^2 / 2          (no schedule changes this)
               + (eta_tilde / tau) sum n_k^2
               + lambda sigma^2 tau sum x_k^2

Everything that depends on the schedule is eta_tilde times sum n^2 / tau + r tau sum x^2 with r = lambda sigma^2 / eta_tilde. So two sets of beliefs with the same r produce the same trades, bit for bit, and the fixed per-share cost never enters at all. A three-dimensional question about misspecification is a one-dimensional one, and an error in the impact coefficient and an error in the variance rate are literally the same error.

slippage robust --quantity 1000000 --horizon 5 --periods 50 \
    --volatility 0.3 --eta 2e-6 --gamma 2.5e-7 --risk-aversion 2e-6

The forecast is wrong to first order; the schedule only to second

believed ratio error forecast error regret ratio
0.5x -50% -19.13% 1.1709% 16
0.9x -10% -3.50% 0.0405% 86
1.1x +10% +3.35% 0.0378% 88
2.0x +100% +27.23% 2.8738% 9

The schedule sits at a minimum of the objective, so moving it by a first-order amount changes the objective by a second-order one. The cost prediction enjoys no such protection. Both exponents are measured by a log-log slope rather than asserted — 2.00 for the regret and 1.00 for the forecast — which is why the last column grows without bound as the estimate improves: at a 1% error it is 876.

So "my impact model is 10% off" should mean two very different things. The bill you quoted the desk is 3.5% wrong. The trades you sent are 0.04% wrong.

How much it can ever cost: exactly one eighth

In the urgent limit the relative regret has a closed form, (sqrt(k) - 1)^2 / (2 sqrt(k)) for a ratio believed k times its true value, whose expansion is eps^2 / 8. A factor-of-two error costs 6.07% of the schedule-dependent objective and no more, ever.

That 1/8 is a ceiling approached from below. Expanding the continuous-time objective in both arguments gives (a^2 - y^2)^2 / 45, so for a patient trader the coefficient falls like y^4 / 45 in y = sqrt(r) T, the horizon measured in half-lives:

horizon in half-lives coefficient
0.1 0.0000022
1.0 0.0129
3.0 0.1063
8.0 0.1250
200 0.1250

Four decades of coefficient between a patient trader and an urgent one. A long horizon is its own hedge against a bad impact estimate, and the quartic is why it is such a good one.

Which way the asymmetry runs, and the robust schedule

The urgent-limit form is symmetric under k -> 1/k, but at finite urgency the coefficient rises with urgency, and that settles both asymmetries at once. Believing the ratio 1.5 times too large costs 1.70 times what believing it 1.5 times too small does, because the first lands the trader in the steeper regime. Read the other way, with the belief fixed, a truth 1.5 times higher costs 1.47 times a truth 1.5 times lower — so over a range of possible truths the binding case is always the fast end.

Which is why robust_urgency leans fast. Over a factor of two either way it puts the belief 11.8% above the geometric midpoint of the range and guarantees 1.92% of the schedule-dependent objective against 2.56% for the midpoint and 2.71% for the arithmetic mean of the range. Over a factor of ten either way the worst case falls from 47.4% to 23.3%, so being robust is worth more the less you know — but the first row of that comparison is the one worth remembering: with the parameters known to a factor of two, the worst a schedule can do is under two per cent.

Checked against the continuum

The discrete calculation is exact for the discrete problem, and the continuous-time closed form is available as a limit to check it against:

F(a, y) = [ (a^2 - y^2) / 2 + (a^2 + y^2) sinh(2a) / (4a) ] / sinh(a)^2

minimised at a = y, where it is y coth y — the Almgren-Chriss objective. The discrete regret approaches F(a, y) / (y coth y) - 1 like one over the interval count, measured at 50, 100, 200, 400 and 800 intervals with the gap halving each time.

Where the half-spread comes from

placement takes a half_spread. shortfall charges one. Neither says where the number comes from, and over bars it is not observable — so slippage.spread implements the three published estimators that infer it from prices, and then measures when any of them can be believed.

from slippage import Estimator, estimate_spread, score_estimators

estimate_spread(bars, Estimator.ABDI_RANALDO).half_spread

All three can produce a negative answer — Roll's sample covariance can come out positive, the other two's averages can be negative — and the published treatment is to set those to zero. That is a one-sided operation on a two-sided sampling error, so every estimate carries unfloored beside it. Over 200 runs of 252 days at 78 trades a day against a 2% daily volatility:

spread / volatility estimator mean bias before flooring
0.05 Roll 45.1bp +351% 7.9bp
0.05 Corwin-Schultz 72.7bp +627% -4.3bp
0.05 Abdi-Ranaldo 22.0bp +120% -1.1bp
1.00 Roll 200.7bp +0.3% 200.7bp
1.00 Corwin-Schultz 192.7bp -3.7% 167.3bp
1.00 Abdi-Ranaldo 199.4bp -0.3% 199.4bp

A 10bp spread against a 2% daily volatility is an ordinary large-cap stock, and at that ratio two of the three have an unfloored mean below zero: they contain no information about the spread, and every basis point they report is the flooring. At a ratio of one they all work, and Roll is unbiased with three times the dispersion of the other two while Corwin-Schultz is the tightest with a bias nine standard errors from zero.

Corwin-Schultz also turns out to be calibrated to a trading frequency rather than to a market: holding everything else and varying only how many trades a day the high and the low are the extremes of, its bias runs from -45.5% at eight trades to +7.0% at 390, crossing zero near a hundred. Roll shows no dependence the runs can resolve and Abdi-Ranaldo a tenth of the range.

slippage spread --bars prices.csv prints all three with their refusal rates; slippage spread --score 0.002 scores them against a spread that is known.

The score table reports the dispersion and the standard error separately, because rmse / sqrt(runs) is not a standard error and it was easiest to mistake for one exactly where the difference matters. rmse is measured about the truth, so rmse² = bias² + variance — an exact identity, asserted as an equality — and a standard error is made of the second half alone. At a spread-to-volatility ratio of 0.05 the two readings differ by a factor of 12.5 for Corwin-Schultz, the most biased of the three; at a ratio of 1.0, where none of them is badly biased, they agree to within a third. The estimator worst overstated is the one whose bias is worst, which is the mechanism rather than an accident of these parameters, and it matters because the standard error is what decides whether a gap between two estimators is real.

Layout

module contents
types, series, benchmarks, costs orders, fills, bars, benchmark prices and the sign convention
shortfall, report implementation shortfall, fill attribution, book-level TCA and outliers
impact, calibration impact models, schedule costs and fitting them to executions
execution Almgren–Chriss trajectories, the efficient frontier, half-life sensitivities
basket the multi-asset liquidation, its eigen-directions, and the leg-by-leg comparison
scheduling the constrained dynamic programme and its re-optimisation policy
volume, simulate volume profiles, TWAP/VWAP/POV schedules and Monte Carlo costs
placement resting a limit order against crossing: fill probability, adverse selection and the cost of each
routing splitting one order across venues: the exact fill distribution, the marginal value of a posted lot, and the router
tracking VWAP tracking error, its irreducible floor, and the trade-off against impact
io, cli, synthetic CSV input and output, the slippage command, synthetic books

Conventions

One sign, carried by the side. Side.BUY.sign is +1 and Side.SELL.sign is -1. Every cost is sign * (execution - reference), so a positive number always means worse than the reference, for either side, and no cost function branches on buy versus sell. The same sign turns an unsigned quantity into signed order flow for the impact models, because impact moves the price against the trader in exactly the direction that makes cost positive.

Decision and arrival are separate timestamps. The gap between a portfolio manager deciding to trade and the order reaching the market is where delay cost accrues. Collapsing them into one field makes that cost unmeasurable.

Interval benchmarks use the order's own window — from arrival to the end of the bar containing the last fill — rather than the whole session. A full-day VWAP scores a twenty-minute order on hours it had no part in.

Windows are half-open on bar starts and bars are never split. Splitting a bar would require inventing a price path inside it that the data does not contain.

VWAP over an interval with no volume raises rather than quietly falling back to TWAP. A caller who asked for VWAP and silently received something else would be comparing against the wrong benchmark without knowing it.

Licence

MIT

Metadata

Release files for slippage-tca 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for slippage-tca 0.1.0
File Size Uploaded Provenance Actions
slippage_tca-0.1.0.tar.gz 377.1 kB With provenance Details for slippage_tca-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for slippage-tca 0.1.0
File Interpreter ABI Platform Actions
slippage_tca-0.1.0-py3-none-any.whl Python 3 none any Details for slippage_tca-0.1.0-py3-none-any.whl

Total release size: 583.7 kB

Release files / slippage_tca-0.1.0.tar.gz

Download URL slippage_tca-0.1.0.tar.gz
Size 377.1 kB
Tags Source
SHA-256 checksum
How to use checksums
e7099b9f30123553dc6d9865d835ba7e5f4d241f1fdf03b418f128a886521865
BLAKE2b-256 checksum
How to use checksums
0e49e8123a16628d297eb07bf68ec1d69fda0ba62fe908c652ca69c9a58215c4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Release files / slippage_tca-0.1.0-py3-none-any.whl

Download URL slippage_tca-0.1.0-py3-none-any.whl
Size 206.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5b5b6047fb452ea876c5a0383130fda2ae7f2a45f4da71a05aacfcc6c61b1112
BLAKE2b-256 checksum
How to use checksums
9d095455a77a7e93031fccc74f1d7902d20d476e4ec7acdda4a8036b02ff5684
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

Security for slippage-tca 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for slippage_tca-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 27, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page