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-tcaline 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
EandVcontainsinh(2κT)and overflow for an impatient trader long before the trajectory does. They are kept asclosed_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κTin 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_pricesproduces 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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.