Date: 2026-08-03 (updated 2026-08-12)
Status: Part 1 partially built — the pure core, schema, settler, and admin CLI have landed; the snapshotter (book producer), public params API, participant read-out, and all of Part 2 are still unbuilt. See Implementation status.
Related:
A-4316
/
PR
#3291
(alee/mmlp-executable-policy,
Andrew
Lee,
draft)
—
the
program's
rules
as
executable
code
(rs/policy/mmlp);
this
engine
builds
on
it
(see
"Builds
on
A-4316"
below);
generic
program-payouts
module
(Part
2
credit
path);
execution
plan
liquidity-program.plan.yaml
(rendered:
docs/plans/liquidity-program.html)
—
phases,
tracks,
live
PR
status;
customer
notice
AX
Liquidity
Program
—
Effective
September
1,
2026
(the
spec);
dynamic
fee
engine
(A-4094,
shipped;
plan
retired),
which
this
program
sits
beside;
incremental
VolumeRing
(PR
#3389,
branch
incremental-volumering-fee-tiering)
—
the
trailing-window
accumulator
this
borrows
its
cadence
shape
from;
sibling
docs
price-band-automation
(mark
/
best-bid-ask
infra),
referral-program
+
partner-rebate-payouts
(USD-credit
plumbing).
No
Linear
epic
or
tickets
exist
yet
—
the
ticket
breakdown
at
the
end
is
a
proposal.
Author: (with Claude)
The program is fully specified to customers and has a hard Sept 1, 2026 effective date (30-day notice already sent), but is completely unbuilt, unowned, and unticketed. This RFC is the engineering plan to close that gap. It is a budgeted, per-product cash reward for resting maker quotes scored on quote quality, not fills — separate from and additive to the volume-tiered fee engine. $200k/month across ~35 products (Appendix B of the spec). The mechanism: snapshot every book once a minute at a random instant, score each trader's two-sided resting size by a quadratic proximity weight around the mark, normalize to per-snapshot shares, and pay each day's per-product pool out pro-rata subject to a 40% anti-concentration cap, a minimum-payout floor, and floor-to-cent settlement.
This was a design document; Part 1 is now substantially built (2026-08-12). The pure scoring/settlement core, the three tables, the epoch settler, and an admin CLI have shipped; the book-reconstruction producer, the public/admin params API, the participant read-out, and the entire Part 2 credit path have not. The Implementation status section maps every RFC proposal to its as-built code (or its absence). The design sections below are preserved as the original plan of record; where a path or crate name diverges from what shipped, the status section is authoritative.
Headline:
the
RFC's
"80%
needs
no
math
and
can
be
built
before
A-4316
lands"
bet
paid
off,
but
not
by
building
on
A-4316
—
that
PR
(#3291
/
ax-policy-mmlp)
never
merged,
and
a
fresh
core
crate
ax-mmlp2
(rs/sdk-internal/mmlp2/)
was
written
instead
(see
Builds
on
A-4316,
now
superseded).
The
pipeline
is
wired
from
the
pure
core
through
settlement
into
the
accrual
ledger,
but
the
snapshotter
that
feeds
it
does
not
exist,
so
mmlp.liquidity_snapshots
has
no
producer
and
the
settler
currently
settles
empty
epochs.
Nothing
credits
cash.
| RFC element | Status | As-built location | PR / ticket |
|---|---|---|---|
Pure
scorer
+
waterfall
+
gates
(RFC
put
in
sdk-internal/src/liquidity_program/) |
shipped
—
as
crate
ax-mmlp2,
not
the
proposed
path |
rs/sdk-internal/mmlp2/src/{score,settle,params}.rs:
mark_price,
score_snapshot,
group_snapshot_rows,
daily_weights,
cap_waterfall,
cap_waterfall_traced,
settle_day;
types
ProgramParams,
BonusWindow,
SnapshotRow,
DaySettlement,
SettlementConfig,
LiquidityAccrualStatus |
#3533 / A-4496 |
mmlp.liquidity_programs
(Postgres,
versioned,
immutable,
bonus_windows JSONB) |
shipped — matches D5 exactly | db/postgres/1.sql:1835;
freeze
trigger
on
UPDATE/DELETE/TRUNCATE |
#3470 / A-4493 |
accrual_engine.liquidity_accruals
(the
seam) |
shipped — matches §4 exactly | db/postgres/1.sql:1874;
append-only
+
status-only
trigger
enforcing
ACCRUED→APPROVED→PAID
/
ACCRUED→FORFEITED |
#3470 / A-4493 |
mmlp.liquidity_snapshots
(ClickHouse,
ReplacingMergeTree(computed_at)) |
shipped — matches data-model row exactly | db/clickhouse/init.sql:537 |
#3470 / A-4493 |
| DB entity layer | shipped | rs/sdk-internal/db/src/mmlp.rs
(DbLiquidityProgram,
DbLiquidityAccrual,
NewLiquidityAccrual);
rs/sdk-internal/clickhouse/src/mmlp.rs
(ChLiquiditySnapshot,
query_for_epoch) |
#3531 / A-4493 |
| Epoch settler (accrual-engine host, §3/§7) | shipped
—
reads
CH
snapshots
→
settle_day
→
writes
accruals,
batched,
US/Central
month
math,
moves
no
money |
rs/accrual-engine/src/mmlp/{mod,settle.rs}
(settle_epoch,
settle_once,
settle_task);
wired
at
rs/accrual-engine/src/lib.rs;
batched
inserts |
#3539 / A-4497, batch #3587 / A-4610 |
| Params authoring | shipped as CLI, not the proposed admin API | rs/admin-cli/src/mmlp.rs
(list,
materialize
from
examples/mmlp.yaml
→
liquidity_programs)
—
the
only
real
writer
today |
#3560 / A-4498 |
Admin
params
GUI
(mirror
FeePrograms.tsx) |
shipped as UI shell, backed by a mock | gui/apps/admin/src/pages/mm-liquidity-programs/
renders
against
mockApi.ts;
no
api-gateway
route
touches
liquidity_programs/liquidity_accruals |
#3572 / A-4593 |
Snapshotter
/
book
reconstruction
from
order_log
(D4,
§1) |
NOT built — the blocking gap | none;
mmlp/mod.rs
notes
"the
snapshotter
(producer)
is
a
follow-up";
mmlp.liquidity_snapshots
has
no
writer,
so
the
settler
settles
empty
epochs |
— |
Public
params
API
on
/instruments
(§6,
"v1-critical") |
NOT built | Instrument.additional_product_specs
still
None;
no
liquidity_program
field,
no
read
endpoint |
— |
| Admin HTTP CRUD for rewards params | NOT built | GUI is mock-only; authoring is CLI-only | — |
| Participant accrued read-out (D6, "v1-valuable") | NOT built | — | — |
| Conservation invariant check (§Testing, §7) | not confirmed present | no
liquidity_conservation
InvariantCheck
found |
— |
| Part 2 — entire credit path (§5, D7) | NOT built | no
liquidity_payouts
table,
no
credit
adapter;
blocked
on
the
program-payouts
module,
which
is
also
unbuilt |
— |
The
RFC's
blocking
pre-work
(does
order_log
decrement
remaining_quantity
on
partial
fills?)
gates
the
snapshotter,
and
the
snapshotter
is
exactly
what's
missing.
Everything
downstream
shipped
against
a
stubbed/empty
snapshot
stream;
the
CH
table's
shape
is
fixed
but
no
code
fills
it.
Q1
remains
the
top
blocker
for
a
live
Part
1,
unchanged
from
the
original
RFC.
The collision is resolved: MMLPv1 was removed in full (A-4812), so only one MMLP remains.
ax-mmlp2,
schema
mmlp.*,
host
rs/accrual-engine/src/mmlp/.
ax-mmlp,
schema
mm_liquidity_performance.*,
routes
rs/api-gateway/src/{mmlp_routes,admin_mmlp_routes}.rs.
The
schema
stays
in
db/postgres/1.sql
permanently,
together
with
the
ax-mm-reports*
S3
buckets
its
reports
rows
point
at,
as
historical
records.
Neither
is
pending
deletion.
mmlp_routes.rs
and
admin_mmlp_routes.rs
now
serve
only
this
program,
at
/mmlp/v2
(client)
and
/admin/mmlp/v2
(admin).
The
MMLP_*
env
prefix
is
likewise
unambiguous,
and
every
var
under
it
belongs
to
this
program.
The build ships as two independent parts, and Part 2 is optional.
Why the seam is here and not elsewhere: the accrual ledger is the clean contract between the two. Part 1's output is a row saying "account X earned $Y for epoch Z, computed and immutable"; Part 2's only job is to credit that row exactly once and mark it paid. Part 1 can run in production for weeks — accruing and displaying — with Part 2 entirely dark, and nothing about Part 1 assumes cash ever moves. This mirrors the partner-rebate-payouts accrual→sweep seam, and is deliberately the same shared plumbing (see D7).
The rest of this RFC labels every table, task, and ticket Part 1 or Part 2.
Superseded (2026-08-12). A-4316 / PR #3291 (
ax-policy-mmlp) never merged. Rather than depend on it, the team wrote a fresh core crateax-mmlp2(rs/sdk-internal/mmlp2/, #3533 / A-4496) that owns the scorer, waterfall, gates, and params directly — the "interim stub → swap in Andrew's crate" seam described below never materialized because there was no crate to swap in. The reasoning below (don't fork the rules, keep one implementation) still holds in spirit:ax-mmlp2is now that one implementation, and the customer doc should be generated from it. The A-4316 coordination questions are moot; the live question is whetherax-mmlp2becomes the doc-generation oracle. The rest of this section is retained for historical context only.
The
program's
real
name
is
MMLP
(Market
Maker
Liquidity
Program),
and
its
rules
already
exist
as
code:
A-4316
/
PR
#3291
ships
rs/policy/mmlp
—
the
scorer,
the
settlement
math,
and
the
params,
default-features = false
as
the
"normative
core…
the
surface
a
production
scorer/payout
engine
should
depend
on."
The
customer
doc
(the
"PDF"
this
RFC's
D2
distrusted)
is
generated
from
that
code;
every
worked
example
is
computed
by
the
same
functions.
This
engine
builds
on
that
crate
—
it
does
not
re-implement
the
rules.
Decision: build on A-4316, don't fork the math. Re-writing scoring/settlement would create two implementations of the rules — the doc says one thing, the engine another — which is the exact drift "codeslaw" exists to prevent.
What
is
"the
rules"
(import
from
ax-policy-mmlp,
never
fork):
scoring.rs)
—
score_snapshot,
((v−d)/v)²,
two-sided
min,
normalized
shares.
payout.rs)
—
daily
bonus-weighted
average,
the
40%
cap
waterfall
(doc
Appendix
A),
min-payout
threshold,
floor-to-cent.
This
is
a
rule,
not
plumbing:
a
maker
disputes
"why
was
I
capped
/
forfeited
/
floored"
against
the
doc,
so
its
arithmetic
must
be
the
one
shared
implementation
—
same
drift-criticality
as
scoring.
What is ours (everything around the rules):
book.rs
is
a
toy
book
(string
traders,
f64
prices)
for
worked
examples;
reconstructing
the
real
attributed
book
from
order_log
and
feeding
his
Book
is
the
engine's
job
and
the
blocking
crux.
Start
now,
leave
scoring/settlement
a
seam.
~80%
of
the
engine
(book
acquisition,
tables,
snapshotter/settler
scaffolding,
params,
API,
payout)
needs
no
math
and
can
be
built
before
A-4316
lands.
The
one
open
seam
is
a
score_snapshot(params, book) -> shares
trait
with
a
stub;
fill
it
with
ax-policy-mmlp
when
the
PR
merges.
Never
write
"our
version"
of
the
scorer
even
temporarily
—
the
interim
stub
returns
fake
scores
for
tests,
not
a
second
rulebook.
Params source — now → later (the codeslaw end-state):
policy/mmlp_program.yaml
(extended
with
the
real
$200k
Appendix
B)
into
mmlp.liquidity_programs
via
an
engine-side
adapter;
the
published
API
reads
the
DB.
fee_program.yaml → fee_schedules).
Also settled by reading the crate:
Decimal
only
through
dollar
settlement,
floored
at
the
credit
step.
Adopt
it
(supersedes
this
RFC's
earlier
"Decimal
everywhere").
One
check:
f64
determinism
across
platforms
vs
the
"replayable"
goal.
Naming
collision
(resolved
by
A-4812):
"MMLP"
was
overloaded
—
mm_liquidity_performance
(quoting-obligation
monitoring/reporting,
no
cash)
vs
rs/policy/mmlp
(this:
quote-quality
rewards).
The
performance
program
has
since
been
removed;
see
the
status
section
above.
Coordination
questions
for
Andrew
(gate,
phase
0):
(1)
A-4316
merge
timeline,
or
do
we
stack
on
alee/mmlp-executable-policy?
(2)
does
the
normative
core
stay
in
rs/policy/mmlp
or
graduate
to
sdk-internal
for
a
runtime
dependency?
(3)
is
ProductParams
/
score_snapshot
/
DailySettlement
the
frozen
API
we
build
against?
Consequence
for
the
decisions
below:
D2
is
largely
resolved
—
the
crate
is
the
oracle
(pin
its
output),
not
"re-derive
the
AI
examples."
Phase
T1
flips
from
"write
the
scorer"
to
"depend
on
ax-policy-mmlp
+
build
the
book
adapter."
Seven decisions gate the build: the three the rollout thread flagged, the architectural fork the spec leaves open, two v1 scope cuts, and the payout-automation split (D7) that structures the whole PR into the two parts above.
q_min
gate
is
per
individual
order,
matching
the
published
PDFThe
published
PDF
(§3.1)
states
sizes
are
not
aggregated
across
a
trader's
orders
"even
at
the
same
price
level"
—
each
order
is
judged
individually
against
q_min.
An
earlier
draft
of
this
RFC
recommended
the
opposite
(sum
size
across
a
level
before
gating)
for
order-splitting
invariance;
that
was
reversed
to
keep
the
engine
faithful
to
the
customer-facing
spec,
which
asserts
its
own
worked
numbers
are
computed
by
the
implementation.
Decision:
gate
each
order
individually
—
an
order
scores
only
if
its
own
size
≥ q_min
(and
d ≤ v);
sub-q_min
orders
are
dropped
before
the
side
sum.
The
load-bearing
observation
still
holds
that
the
gate
is
the
only
place
aggregation
matters:
all
orders
at
one
price
share
the
same
distance
d,
so
Σ w·qᵢ = w·Σqᵢ
and
the
score
of
the
surviving
orders
is
unchanged
whether
summed
per-order
or
per-level.
The
engine
therefore
still
aggregates
qualifying
size
per
(account,
side,
price)
for
the
score
—
it
just
applies
the
q_min
gate
to
each
qᵢ
first,
not
to
Σqᵢ.
The
cost
is
the
loss
of
split-invariance
(a
fat-finger
split
below
q_min
silently
forfeits,
and
fragmentation
is
penalized);
the
benefit
is
that
engine
and
doc
cannot
diverge.
At
q_min = 1
(shipping
today)
the
two
rules
coincide
—
every
integer-contract
order
qualifies
individually
—
so
this
reversal
is
behavior-neutral
for
current
params
and
only
bites
once
a
product
publishes
q_min ≥ 2.
Pinned
in
the
scorer
test
suite
so
the
rule
cannot
silently
regress.
ax-policy-mmlp
crate
is
the
oracle
(superseded
by
A-4316)Original framing: the PDF's worked examples were AI-generated and not hand-verified, so re-derive the math and don't trust the tables.
Resolved
by
A-4316:
the
doc
is
now
generated
from
rs/policy/mmlp,
and
every
worked
example
is
computed
by
the
same
scoring.rs/payout.rs
functions
the
engine
will
call
—
so
the
crate,
not
the
PDF
and
not
a
hand
re-derivation,
is
the
source
of
truth.
Recommendation:
depend
on
the
crate
and
pin
its
output
as
the
insta
fixtures;
the
engine
and
the
doc
can
never
disagree
because
they
run
the
same
code.
(As
a
sanity
check
this
RFC
hand-re-derived
Examples
1
and
3
and
they
hold
—
Ex1
Alice 0.8 / Bob 0.2;
Ex3's
two-round
waterfall
conserving
to
$166.67 with `$66.66
/
$66.66
/
$33.33`
paid
—
consistent
with
the
crate
being
correct.)
See
"Builds
on
A-4316"
above.
The program is additive to fee tiers, so a barely-trading MM on a thin product can collect program cash and deep fee discounts at once; nothing caps the sum. Andrew floated reintroducing a −0.5bps top maker tier; Brett vetoed any negative-fee tier ("introducing −0.5bp makes us lose money").
Recommendation:
v1
keeps
the
program
purely
additive,
adds
a
per-(MM,
product)
combined_take
metric
(program
cash
earned
+
notional
value
of
the
fee
discount
vs.
the
top
public
maker
rate),
and
defers
any
cap
to
a
data-driven
fast-follow.
Negative-fee
tiers
are
out
(Brett's
veto
stands).
Rationale:
a
combined
cap
couples
two
independently-budgeted
systems
and
forces
a
contestable
definition
of
"discount
relative
to
what
baseline";
meanwhile
the
program's
own
40%
anti-concentration
cap
and
the
fixed
per-product
monthly
pool
already
bound
total
cash
outflow
with
certainty.
The
right
first
move
is
to
measure
whether
double-dipping
is
real
before
building
machinery
to
cap
it
—
the
metric
is
cheap
and
answers
the
question.
If
the
data
shows
abuse,
a
combined
cap
or
a
claw
at
settlement
time
slots
in
without
touching
the
scorer.
order_log
post-hoc,
don't
stand
up
a
live
account-aware
snapshotter
(pending
one
verification)This
is
the
central
architectural
fork
and
the
spec
is
silent
on
it.
Scoring
is
per
trader,
which
needs
per-order
→
account
attribution
at
the
sample
instant.
The
public
L3
book
in
marketdata-publisher
(rs/marketdata-publisher/src/lib.rs)
carries
no
account
identity;
EP3
is
the
source
of
truth
for
resting
orders;
order-gateway
holds
per-account
OpenOrders
in
memory
(rs/order-gateway/src/open_orders.rs)
but
only
as
live
state,
not
a
queryable
as-of
snapshot.
Two options:
order_log.
order_log
(db/clickhouse/init.sql:83-115)
is
a
full
per-order
lifecycle
event
stream
carrying
account_id,
user_id,
symbol,
side,
price,
quantity,
remaining_quantity,
order_state,
event_timestamp_ns.
An
order
rests
at
instant
t
iff
it
entered
a
working
state
before
t
and
its
terminal
event
is
after
t
(or
absent).
Pick
the
random
instant
per
minute
after
the
fact
and
reconstruct
each
book
from
the
log.
Recommendation:
Option
B,
contingent
on
verifying
order_log
captures
intra-life
resting-size
changes
(partial
fills).
Post-hoc
random
sampling
is
strictly
more
gaming-resistant
than
a
live
snapshotter
—
a
trader
cannot
observe
or
be
tipped
off
to
the
sample
instant
if
it
is
chosen
after
the
minute
closes
—
and
it
reuses
recon-engine
+
ClickHouse
with
zero
new
always-on
writers,
and
it
is
deterministically
replayable
for
disputes
(recompute
a
whole
day
from
the
log).
The
one
risk
is
precision:
the
comment
at
db/clickhouse/init.sql:67-81
says
only
order_state = Pending
carries
full
details
and
rows
are
emitted
for
state
transitions,
so
partial
fills
may
not
decrement
resting
size
in
order_log
—
which
would
make
reconstructed
resting
size
stale
between
placement
and
terminal.
Verification
task
(blocking):
confirm
whether
partial
fills
emit
order_log
rows
with
updated
remaining_quantity.
If
yes
→
Option
B
as-is.
If
no
→
the
cheaper
fix
is
to
enrich
order_log
(or
a
sibling
resting_order_events
stream)
with
resting-size
deltas
—
far
less
than
a
new
live
service
—
and
only
fall
back
to
Option
A
if
enrichment
proves
infeasible.
The
market_snapshots
best-bid/offer
sampler
(db/clickhouse/init.sql:319-336)
is
the
precedent
for
per-symbol
snapshot
cadence
and
an
independent
cross-check
on
the
reconstructed
mark.
B(t)
bonus
multiplier:
engine
in
v1,
window
admin
UI
fast-followThe
payout
formula
weights
snapshots
by
B(t).
Ship
the
weighted-average
formula
in
v1
with
all
windows
defaulting
to
B(t) = 1
(the
spec's
default),
so
payouts
are
correct
from
day
one
with
no
window
config.
The
admin
surface
to
define
non-unit
windows
is
a
fast-follow
—
it
changes
incentives,
not
correctness,
and
no
product
needs
a
non-unit
window
on
Sept
1.
The spec gates these tools to participants who earned ≥1 reward in the trailing 30 days — which by definition nobody has on Sept 1. Scoring → accrual → published-params API are v1-critical; the dashboard and the on-book band highlight can land in the weeks after launch. But note: a thin "accrued today" read-out is v1-valuable — with Part 2 dark (D7), the accrual number is the only proof to a participant that the program is working, so a minimal accrued-balance view ships in Part 1 even though the full gated dashboard is fast-follow.
The
spec
tells
customers
rewards
are
"credited
to
the
customer's
AX
account
at
the
end
of
each
daily
epoch."
But
a
scheduled
liquidity-reward
job
that
moves
cash
still
does
not
exist
in
AX.
The
transaction-engine
Deposit
mechanism
exists,
and
treasury-engine
now
has
shared
settlement
consumers
for
manual
and
automated
Anchorage
deposit
decisions:
treasury_engine.deposit_credit_attempts
records
immutable
request
identity,
while
treasury_engine.deposit_credits
owns
the
canonical
one-effect-per-deposit
result
and
its
transaction_event_id.
Settlement
writes
ClickHouse
before
committing
Postgres;
a
later
Postgres
commit
failure
can
leave
an
orphan
ClickHouse
row
for
reconciliation.
That
core
is
deposit-specific
and
is
not
runtime
wiring
for
a
liquidity
sweep;
the
existing
ax_scheduler::run_periodic
jobs
still
only
compute
and
update
rows
(fee
refresh,
leaderboard).
The
partner-rebate
monthly
sweep
is
RFC-stage,
blocked
on
tax/withholding,
currency
(USDfiat
vs
USDC),
and
approval
policy.
Reward
cash
is
plausibly
1099-reportable
—
a
Finance/Legal
gate,
not
an
eng
one.
Recommendation:
split
the
build.
Part
1
(v1)
computes
and
writes
an
immutable
accrual
ledger
row
per
(epoch,
symbol,
account)
and
moves
no
money.
Part
2
(separate,
later,
optional)
credits
approved
accruals
—
starting
human-gated
(an
admin
approves/pays
a
closed
period
via
an
endpoint),
graduating
to
an
automatic
daily
sweep
only
after
tax
+
currency
+
approval
policy
clear.
Do
not
build
a
liquidity-specific
credit
path
at
all
—
Part
2
is
an
adapter
over
the
generic
program-payouts
module
(a
payout_program_config
row
for
program_kind = Liquidity
+
enqueuing
approved
accruals
into
program_payout_requests);
the
same
module
serves
referral
and
treasury
deposits.
Tradeoff / why: the spec promised daily auto-credit, so there is a promise-vs-readiness gap to surface to whoever owns the program. But splitting de-risks Sept 1 decisively — Part 1 is un-gated pure-compute + read APIs and fully honors "scoring is live, params are published, you can see what you earned," while the genuinely novel, Finance-gated cash-mover is decoupled and can mature on its own clock without a hard date on it. Building the sweep once, deliberately, as shared infra also stops us paying for "robot pays cash" plumbing three times (liquidity, partner-rebate, treasury deposits). The cost is that an accrual is not a payment: if Part 2 slips, participants have earned visible balances they can't yet withdraw — manageable with a human-approved manual pay per period in the interim, which Part 2's first increment delivers.
Net
Part
1
(v1)
scope:
book
reconstruction
+
scorer
+
two-sided
min
+
normalization
+
B(t)-weighted
daily
settlement
+
waterfall
+
minimum-payout
+
floor-to-cent
→
immutable
accrual
ledger
+
published-params
API
+
a
minimal
accrued
read-out
+
conservation
invariant
+
alerting.
No
cash
movement.
Net
Part
2
(later,
optional)
scope:
the
shared
credit
path
—
idempotent
payout
ledger,
double-entry
Deposit,
an
admin
approve/pay
endpoint
(human-gated),
then
a
scheduled
sweep
—
plus
the
tax/currency/approval
decisions
that
gate
it.
Fast-follow
(either
part):
full
rewards
dashboard,
on-book
band
highlight,
B(t)
window
admin,
combined-take
cap
(if
the
metric
justifies
it).
Per the spec (re-stated so this RFC is self-contained; §-refs are to the customer notice):
P
(Appendix
B:
$200k/mo
across
~35
products,
tiered
Core
$10.5k
→
Active
$7k
→
Emerging
$5k
→
Bootstrap
$3.5k).
The
daily
pool
is
P_daily = P / n,
n
=
days
in
the
calendar
month.
p_mark
is
the
midpoint
of
best
bid
/
best
offer;
an
empty,
one-sided,
locked,
or
crossed
book
has
no
mark
and
the
snapshot
does
not
score.
S_order = ((v − d)/v)² × q,
where
v
=
published
qualifying
half-spread
band
(bps),
d
=
order
distance
from
mark
(bps,
unrounded;
non-scoring
when
d > v,
and
d = v
is
inclusive
but
weights
to
exactly
0),
q
=
order
size.
Each
order
must
individually
meet
q_min
(per-order
gate,
D1).
S_bids = Σ bid scores,
S_asks = Σ ask scores,
S_total = min(S_bids, S_asks)
—
one-sided
quoting
scores
0.
N_i = S_i / Σ_j S_j
within
each
scoring
snapshot
(each
scoring
snapshot
distributes
100%
of
its
weight).
P_i = P_daily × (Σ_t N_i(t)·B(t)) / (Σ_t B(t))
—
a
B(t)-bonus-weighted
average
of
normalized
scores
over
the
day's
scoring
snapshots.
P_daily
per
trader,
applied
by
the
iterative
pro-rata
waterfall
(spec
Appendix
A);
(2)
minimum
payout
=
min($10, 1% × P_daily)
—
sub-threshold
forfeited,
no
rollover;
(3)
exact-decimal
settlement,
floor-to-cent,
exchange
keeps
the
residue.
P,
v,
q_min,
B(t).
The good news is that almost every primitive already exists; the genuinely new code is the scorer, the waterfall, the payout ledger, and the params surface.
CheckContext
carries
both
pools
—
rs/recon-engine/src/check.rs:14-20),
with
an
incident.io
sink
(rs/recon-engine/src/incident_io.rs)
and
a
semaphore-gated
scheduler
(rs/recon-engine/src/lib.rs:519-618).
The
dynamic
fee
engine
runs
here
as
a
daily
periodic
task
(rs/recon-engine/src/fees.rs),
and
the
incremental
VolumeRing
(PR
#3389)
maintains
trailing-window
volume
state
in
per-day
buckets
rebuilt
from
ClickHouse
—
the
closest
sibling
to
a
per-day
snapshot-score
accumulator.
The
scorer
is
"the
same
loop
with
a
different
sink,"
per
the
fee-engine
plan's
reuse
note.
market_snapshots
(db/clickhouse/init.sql:319-336)
already
samples
best_bid_price/best_offer_price
per
symbol.
Confirm
the
program's
midpoint-of-best-bid/ask
matches
the
mark
defined
there;
the
program's
"no
mark
on
locked/crossed/one-sided"
rule
is
a
small
predicate
on
top.
handle_transactions_with_txn
(rs/transaction-engine/src/lib.rs:239)
writes
double-entry
balance
moves
with
rust_decimal::Decimal,
rounding
to
BALANCE_DECIMAL_PLACES = 12
(:83, :333)
into
current_balances.usd_balance
(Postgres
NUMERIC,
db/postgres/1.sql:166-174),
mirrored
to
ClickHouse
transactions
(Decimal128(12),
:288-317).
The
partner-rebate-payouts
RFC
established
the
pattern
this
program
copies:
an
idempotency-keyed,
append-only
payout
ledger
(mirroring
treasury_engine.deposit_credit_attempts,
db/postgres/1.sql:1475-1510)
whose
settlement
step
debits
AX_FEE_ACCOUNT_ID
(rs/sdk-internal/src/account_id.rs:10)
and
credits
the
trader
via
TransactionKind::Deposit
with
a
URN
reference_id.
lending_transactions
(rs/transaction-engine/src/lending.rs:23-70)
is
the
double-entry
template.
/instruments
endpoint
(rs/api-gateway/src/public_routes.rs:204-264)
→
SDK
Instrument
(rs/sdk/src/types/trading.rs:31-84),
which
already
carries
an
extensible
additional_product_specs
map
—
the
exact
pattern
for
exposing
P,
v,
q_min,
B(t).
gui/apps/admin/src/pages/fee-programs/FeePrograms.tsx)
backed
by
GET/POST /api/admin/fee-schedules
(rs/api-gateway/src/admin_routes.rs:2464-2527)
with
an
immutable-publish,
versioned-schedule
pattern
(DbFeeSchedule,
rs/sdk-internal/db/src/entities.rs:6838-6915)
—
the
template
for
a
liquidity-program
params
admin
surface.
DbInstrument
PgJson<T>
column
pattern
(pyth_config,
ornn_config,
cme_future_config
—
rs/sdk-internal/db/src/entities.rs:709-759),
and
closed
text-column
discriminators
follow
InstrumentCategory
/
FeeWindowShape
(serde
rename_all
+
FromStr).
order_log
+
published
params,
for
dispute
resolution
and
for
pinning
the
math
in
tests.
P,
v,
q_min,
B(t))
are
live
in
the
public
API
on
Sept
1,
versioned
so
historical
payouts
reconstruct
against
the
params
in
force
at
the
time.
order_log;
we
add
no
live
book
subscriber
if
D4's
verification
passes.
B(t)
window
admin
UI
and
the
rewards
dashboard
in
v1
(D5,
D6)
—
fast-follow.
The pipeline is five stages. The first four are Part 1 (three pure functions + one book-reconstruction read); the accrual ledger is the seam; the fifth stage is Part 2 and is optional/gated (D7).
order_log (ClickHouse) published params (Postgres, versioned)
│ │
▼ [random instant t, per minute] │
reconstruct_book(symbol, t) ──────────────┐ │ ┌─ PART 1 (v1) ─ no cash moves
│ resting orders w/ account attrib │ │ │
▼ ▼ ▼ │
score_snapshot(book, params) ── pure ──▶ per-trader S_bids/S_asks/S_total, N_i, is_scoring
│ │
▼ append per (symbol, epoch_start_ts, snapshot_minute, account)│
mmlp.liquidity_snapshots (ClickHouse, recomputable) │
│ │
▼ [at configured epoch close] │
settle_day(snapshot rows, P_daily, B(t)) ── pure ──▶ weights │
│ │
▼ │
cap_waterfall + min-payout + floor-to-cent ── pure ──▶ computed $
│ │
▼ write immutable computed amount │
══ accrual_engine.liquidity_accruals (Postgres) ═ THE SEAM ═══┘
│
▼ ┌─ PART 2 (later, optional, human-gated — D7) ─────────
│ │ admin approves a closed period ─or─ scheduled sweep
▼ │ (blocked on tax / currency / approval)
credit USD (transaction-engine, double-entry) + liquidity_payouts ledger (idempotent)
A
recon-engine
periodic
task
(liquidity_snapshotter,
modeled
on
refresh_fee_rates_task)
runs
on
a
1-minute
tick.
For
each
minute
it
picks
a
random
instant
within
the
just-closed
minute
(so
the
sample
cannot
be
anticipated),
and
for
each
program-enrolled
symbol
reconstructs
the
resting
book
as-of
that
instant
from
order_log:
resting orders at t = { o in order_log[symbol]
: o entered working state at ≤ t
∧ o has no terminal event at ≤ t }
each carries (account_id, side, price, resting_qty, distance-computable from mark)
The
mark
p_mark = (best_bid + best_offer)/2
is
computed
from
the
reconstructed
book's
best
levels
(raw
best
per
spec
§3.1,
not
filtered
by
q_min
—
see
Risk
R1),
cross-checked
against
market_snapshots
at
the
nearest
timestamp.
No
valid
mark
→
the
snapshot
is
marked
non-scoring
and
produces
no
score
rows.
resting_qty
is
the
crux
precision
question
(D4):
if
order_log
does
not
decrement
size
on
partial
fills,
reconstructed
size
is
stale
for
partially-filled
resting
orders.
Resolve
via
the
D4
verification
before
this
task
ships.
Randomness
note:
Math.random/wall-clock
entropy
is
fine
in
a
live
service
(unlike
the
deterministic
workflow
context);
seed
from
the
OS
RNG,
and
record
the
chosen
instant
on
the
snapshot
row
so
the
day
is
replayable.
All
scoring
is
one
pure
function
with
no
I/O,
in
rs/sdk-internal
(shared,
and
reachable
by
the
public
rulebook
doc-gen
without
leaking
datastores
per
the
SDK
rule):
// rs/sdk-internal/src/liquidity_program/score.rs
pub struct ProgramParams { pub v_bps: Decimal, pub q_min: Decimal /* + b_window lookup */ }
pub struct RestingOrder { pub account: AccountId, pub side: Side, pub price: Decimal, pub qty: Decimal }
pub struct SnapshotScore { pub per_trader: HashMap<AccountId, TraderScore>, pub total: Decimal }
pub struct TraderScore { pub s_bids: Decimal, pub s_asks: Decimal, pub s_total: Decimal, pub n_share: Decimal }
/// None => non-scoring snapshot (no valid mark, or Σ S_total == 0).
pub fn score_snapshot(book: &[RestingOrder], mark: Option<Decimal>, p: &ProgramParams) -> Option<SnapshotScore>;Internals, exactly per spec:
mark?
—
bail
to
None
if
absent.
q_min
(D1):
drop
any
order
with
qty < q_min,
then
sum
the
surviving
qualifying
size
per
(account,
side,
price).
d = |price − mark| / mark × 10_000
bps;
skip
if
d > v;
else
w = ((v − d)/v)²
(note
d = v ⇒ w = 0,
inclusive
but
zero
—
Frank
in
Example
2),
s = w × Σ(qualifying qty).
s_bids,
s_asks,
s_total = min(s_bids, s_asks).
total = Σ s_total;
if
total == 0
→
None
(non-scoring).
Else
n_share = s_total / total.
Decimal
throughout
(rust_decimal),
never
float
—
bps
distances
and
the
quadratic
must
be
exact
to
match
the
pinned
snapshots.
Guard
the
/v
and
/total
divisions
(v > 0
from
params
validation;
total > 0
checked).
At
configured
epoch
close,
a
second
recon-engine
task
aggregates
the
epoch's
mmlp.liquidity_snapshots
per
(symbol,
account)
into
a
B(t)-weighted
average,
then
runs
the
cap
waterfall,
min-payout
gate,
and
floor-to-cent
—
all
pure:
// rs/sdk-internal/src/liquidity_program/settle.rs
pub fn daily_weights(snapshots: &[SnapshotRow], b: &BonusSchedule) -> HashMap<AccountId, Decimal>; // Σ N·B / Σ B
/// Iterative pro-rata waterfall (spec Appendix A). Cap C = 0.40 × pool, fixed for the run.
/// Bounded (≤ n rounds), order-independent, conserving (payouts + retained == pool).
pub fn cap_waterfall(weights: &HashMap<AccountId, Decimal>, pool: Decimal) -> WaterfallResult;
pub struct WaterfallResult { pub allocations: HashMap<AccountId, Decimal>, pub retained: Decimal }Coverage
does
not
prorate
the
pool
(2026-09-03).
The
pool
fed
to
the
waterfall
is
the
full
P_daily,
whatever
the
epoch's
coverage.
Weights
normalize
over
the
minutes
that
actually
scored,
which
is
exactly
what
§3.3
describes
—
"non-scoring
snapshots
count
toward
neither
the
numerator
nor
the
denominator"
—
so
each
day's
pool
is
distributed
entirely
across
the
minutes
where
someone
provided
two-sided
liquidity,
and
a
day
with
zero
scoring
snapshots
pays
nothing
because
it
is
never
settled.
Conservation
reads
accrued + forfeited + retained == P_daily,
and
the
cap
C = 40% × P_daily
and
the
min($10, 1% × P_daily)
threshold
are
computed
against
that
same
full
pool
(Appendix
A,
Example
3).
Coverage
remains
a
first-class
operational
figure,
not
a
payout
input.
expected
is
the
epoch's
DST-adjusted
minute
count
less
the
daily
15:00–16:00
US/Central
maintenance
hour
(1380,
or
1320/1440
across
DST;
MAINTENANCE_WINDOW
in
epoch.rs,
since
no
book
exists
to
score
in
it)
and
covered
is
the
number
of
scoring
minutes
present
outside
that
hour;
rows
scored
inside
it
are
ignored
and
logged
(scoring_rows
in
the
pure
core,
ahead
of
epoch_pool),
so
the
numerator
and
denominator
leave
out
the
same
hour
by
construction.
Over-coverage
therefore
means
duplicate
rows
only,
and
is
still
refused
rather
than
clamped.
Both
figures
are
displayed
on
the
admin
page
and
alerted
on,
and
MMLP_SETTLE_MIN_COVERAGE_PCT
can
hold
a
thin
epoch
back
from
settling
entirely
—
but
neither
ever
pays
a
reduced
amount.
The
accepted
trade-off
is
that
a
genuine
sampling
outage
concentrates
a
whole
day's
pool
on
whoever
quoted
through
it.
An
earlier
revision
of
this
RFC
specified
P_daily × covered / expected
as
an
engine
rule
on
top
of
the
spec;
it
was
removed
before
the
settler
was
enabled
on
prod,
because
it
paid
off-terms
on
every
symbol
that
is
not
quoted
around
the
clock.
The
waterfall,
verbatim
from
Appendix
A:
start
with
the
full
pool
R
and
all
positive-weight
traders
active;
provisionally
allocate
R
pro-rata
by
weight
among
the
active
set;
any
allocation
> C
is
fixed
at
exactly
C,
removed
from
active,
C
deducted
from
R;
repeat
until
no
active
allocation
exceeds
C,
or
the
active
set
empties
(remainder
retained).
Because
every
violator
in
a
round
is
compared
against
the
same
fixed
constant
C,
the
result
is
order-independent
—
implement
with
a
stable
pass
over
a
sorted-by-account
set
so
the
same
input
always
produces
byte-identical
output.
Then,
per
surviving
trader:
min-payout
gate
accrued ≥ min($10, 0.01 × P_daily)
evaluated
on
the
exact
pre-round
amount
(sub-threshold
→
forfeit,
retained);
then
floor
to
cent
(sub-cent
residue
retained).
daily_weights,
cap_waterfall,
and
the
gates
compose
into
one
settle_day
that
returns,
per
trader:
pre_cap_usd,
post_cap_usd,
accrued_usd
(floored),
and
per-epoch
totals
of
forfeited
+
retained
for
the
conservation
invariant.
settle_day's
output
is
written
to
accrual_engine.liquidity_accruals
(Postgres)
—
one
immutable
row
per
(epoch_start_ts,
symbol,
account):
epoch_start_ts,
epoch_end_ts,
pre_cap_usd,
accrued_usd,
and
a
status
(ACCRUED
for
payees,
FORFEITED
for
sub-min-payout
traders;
later
APPROVED/PAID).
This
is
the
extent
of
Part
1's
write
side:
it
records
what
was
earned
and
moves
no
money.
The
row
is
the
contract
handed
to
Part
2.
There
is
no
separate
roll-up
table
—
the
conservation
invariant
is
derived:
retained = P_daily − Σ accrued_usd
(above-cap
+
floor
residue),
and
paid/forfeited
totals
are
sums
by
status.
accruals
+
mmlp.liquidity_programs
is
the
single
source,
so
accrued + forfeited + retained == P_daily
holds
without
any
cash
path
or
a
second
table.
Immutability,
and
why
settlement
never
revises.
A
row,
once
written,
never
moves
—
that
is
what
makes
the
seam
safe
for
a
Part
2
that
may
already
have
credited
it.
An
epoch
is
only
ever
settled
once,
so
nothing
needs
to
revise
it:
settle
once,
ON CONFLICT DO NOTHING.
A
re-run
is
a
no-op
and
a
racing
writer
collides
on
the
PK.
What
makes
settling
once
safe
is
a
grace
period:
an
epoch
is
not
a
candidate
until
epoch_end_ts <= now − grace,
with
grace ≥
the
snapshotter's
lookback
(enforced
at
config
load).
The
snapshotter
only
backfills
inside
its
lookback,
and
a
pass
nets
at
least
one
minute,
so
a
full
lookback
of
backlog
drains
within
one
lookback.
Past
the
grace,
coverage
is
final.
Without
it
an
epoch
could
settle
mid-backfill
and
permanently
retain
the
pool
the
late
minutes
would
have
paid.
An
out-of-band
re-score
(an
ad-hoc
re-sample
over
an
older
window,
a
manual
correction)
is
deliberately
not
repaired
automatically
—
for
an
immutable
money
ledger
that
is
an
operator
decision,
not
a
background
task.
An
earlier
draft
kept
a
revision
column
in
the
PK
as
the
place
such
a
correction
would
land;
nothing
ever
wrote
a
non-zero
one
and
it
was
dropped
(#3998).
The
PK
is
(epoch_start_ts, symbol, account_id),
and
a
correction
is
a
manual
ledger
operation,
not
a
second
row.
Everything below moves cash and is Part 2 — gated per D7, shippable weeks after Part 1, and built as shared plumbing (the same path partner-rebate needs), not liquidity-specific. It reuses the partner-rebate-payouts design.
POST /admin/liquidity/approve
(flips
matching
accrual_engine.liquidity_accruals
rows
ACCRUED → APPROVED),
then
a
pay
action
credits
the
approved
set.
Only
once
tax/currency/approval
clear
does
an
automatic
run_periodic
sweep
—
selecting
status = APPROVED
rows
for
closed
epochs
—
replace
the
manual
pay.
The
sweep
and
the
manual
endpoint
target
the
same
idempotency
key,
so
they
collide
on
replay
instead
of
double-paying.
liquidity_payouts
keyed
by
URN
ax-liquidity://<epoch_start_ts>/<symbol>/<account_id>
(PK
collision
→
already
paid,
skip
—
exactly-once
across
retries/restarts);
double-entry
via
handle_transactions_with_txn
—
TransactionKind::Deposit
to
the
trader,
matching
debit
from
the
program-funding
system
account
(AX_LIQUIDITY_PROGRAM_ACCOUNT_ID
or
AX_FEE_ACCOUNT_ID
—
Q4),
both
stamped
with
the
URN
reference_id
and
a
shared
event_id;
then
flip
the
accrual
APPROVED → PAID.
liquidity_payouts
records
accrued,
paid,
outcome ∈ {CREDITED, FAILED},
transaction_event_id
(NULL
on
FAILED),
actor,
reason.
Append-only,
freeze-triggered
(mirroring
treasury_engine.deposit_credit_attempts):
corrections
are
new
rows.
mmlp.liquidity_programs
(per
symbol,
per
calendar
month:
pool_usd,
v_bps,
q_min,
effective_at),
not
a
DbInstrument
JSON
column
—
params
change
monthly
and
disputes
need
the
params
in
force
at
payout
time,
exactly
like
fee_schedules
immutable-by-version.
B(t)
windows
live
in
a
bonus_windows JSONB
column
on
the
same
row
(PgJson<Vec<BonusWindow>>,
each
[start_min, end_min)
of
the
1440-minute
epoch
+
multiplier)
so
they
version
with
params
and
need
no
join;
absent/empty
⇒
B(t) = 1.
/instruments
/
the
Instrument
SDK
type
(rs/sdk/src/types/trading.rs:31-84)
with
an
optional
liquidity_program: Option<LiquidityProgramParams>
sub-struct
(pool_usd,
v_bps,
q_min,
bonus_windows),
or
populate
additional_product_specs
if
we
want
zero
SDK-type
churn.
SDK
rule:
expose
only
these
four
published
fields
—
never
the
scorer
internals,
order_log,
ClickHouse,
or
service
names.
FeePrograms.tsx
—
list
products
with
pool/v/q_min,
edit
per-month
params
(immutable
publish
→
new
version),
and
(fast-follow)
B(t)
windows.
The
rewards
dashboard
and
on-book
band
highlight
(D6)
are
separate
fast-follow
surfaces
gated
to
earners.
The
Part
1
tasks
(snapshotter,
settler)
live
in
recon-engine
for
the
same
reasons
the
fee
engine
does:
it
already
carries
the
Postgres
+
ClickHouse
pools,
the
cron
scheduler,
and
the
incident.io
sink,
and
"engine
that
writes
exchange
state"
wants
one
clear
owner.
They
are
periodic
tasks
alongside
refresh_fee_rates_task,
not
InvariantChecks
(they
produce
state,
not
verify
it)
—
though
a
self-recon
invariant
should
be
added
(§Testing)
so
recon-engine
polices
its
own
conservation.
If
D4
forces
Option
A
(a
live
subscriber),
that
subscriber
must
be
an
obligate
singleton
and
should
still
write
into
recon-engine's
tables
rather
than
settle
directly.
The
Part
2
credit
path
is
separable
by
construction
(D7):
it
reads
accrual_engine.liquidity_accruals
and
writes
cash.
It
can
start
as
an
admin
endpoint
in
api-gateway
(human-gated
pay)
and
later
graduate
to
a
run_periodic
sweep
—
hosted
wherever
the
shared
partner-rebate
sweep
lands,
since
it
is
the
same
plumbing.
Keeping
the
writer
(Part
1)
and
the
payer
(Part
2)
in
separate
services
with
the
ledger
between
them
is
the
whole
point
of
the
seam.
Following
the
"high-volume
append
→
ClickHouse;
params/ledger
→
Postgres"
split,
and
the
declarative
Atlas
convention
(db/postgres/1.sql;
ClickHouse
in
db/clickhouse/init.sql).
Four
tables
—
deliberately
minimal
(see
the
schema-simplification
note
below
for
the
three
merges
that
got
us
here
from
a
naive
six).
| Table | Part | Store | Why | Shape (key columns) |
|---|---|---|---|---|
mmlp.liquidity_snapshots |
1 | ClickHouse | ~1,440 snapshots × ~35 products × N traders/day, recomputable, queried by (symbol, epoch_start_ts) at settlement | symbol,
epoch_start_ts,
epoch_end_ts,
snapshot_minute
(0–1439
offset
from
epoch_start_ts),
snapshot_ts_ns
(the
recorded
random
instant),
account_id,
account_score,
snapshot_score_total
(the
denominator),
bonus_multiplier,
computed_at.
ReplacingMergeTree(computed_at)
on
(symbol, epoch_start_ts, snapshot_minute, account_id)
⇒
latest
recompute
wins;
settlement
picks
each
minute's
newest
(computed_at, snapshot_ts_ns)
with
a
window
function
rather
than
FINAL,
which
would
keep
the
participants
an
earlier
pass
had
and
a
later
one
didn't.
n_share = account_score / snapshot_score_total
is
derived
at
read
when
the
total
is
positive,
not
stored;
s_bids/s_asks
are
debug-only
and
not
persisted.
Rows
are
written
for
participating
accounts;
account_score = 0
means
participation
without
score,
while
absence
means
no
participation. |
mmlp.liquidity_programs |
1 | Postgres | low-volume, versioned, joined at settlement + served to API; disputes need point-in-time | symbol,
effective_at TIMESTAMPTZ,
pool_usd NUMERIC,
v_bps NUMERIC,
q_min NUMERIC,
bonus_windows JSONB
(PgJson<Vec<BonusWindow>>,
absent/empty
⇒
B(t)=1),
created_at.
Immutable
by
(symbol,
effective_at). |
accrual_engine.liquidity_accruals |
1 | Postgres | the seam — immutable computed amount per (epoch, symbol, account); Part 1 writes it, Part 2 reads it. No cash. Also the sole conservation source. | epoch_start_ts,
epoch_end_ts,
symbol,
account_id,
pre_cap_usd NUMERIC
(uncapped
reward
amount),
accrued_usd NUMERIC
(post-cap
allocation,
floored),
status TEXT
(ACCRUED/APPROVED/PAID/FORFEITED
—
sub-min-payout
traders
stored
as
FORFEITED),
computed_at,
paid_at.
Unique
(epoch_start_ts,
symbol,
account).
Immutable
except
allowed
status
transitions. |
liquidity_payouts |
2 | Postgres | the money ledger — PK idempotency, freeze trigger, transactional credit. Only table Part 2 adds. Kept separate to match the shared partner-rebate ledger shape (D7). | idempotency_key TEXT PK
(ax-liquidity://…),
epoch_start_ts,
symbol,
account_id,
accrued_usd NUMERIC,
paid_usd NUMERIC,
outcome TEXT
(CREDITED/FAILED),
transaction_event_id TEXT UNIQUE,
actor,
reason,
attempt_ts.
Append-only. |
Money
is
rust_decimal::Decimal
in
Rust
/
NUMERIC
in
Postgres
/
Decimal128(12)
in
ClickHouse,
consistent
with
the
transaction
engine
(BALANCE_DECIMAL_PLACES = 12).
Part
1
ships
three
tables
and
writes
zero
money;
Part
2
adds
only
liquidity_payouts.
A naive schema wants six tables; three collapse without losing anything:
liquidity_bonus_windows
→
a
bonus_windows JSONB
column
on
mmlp.liquidity_programs.
Windows
are
per-(symbol,
effective_at),
versioned
identically
to
params
(1:1),
so
a
sibling
table
just
adds
a
redundant
version
axis
and
a
join.
As
a
typed
PgJson<Vec<BonusWindow>>
(the
DbInstrument
pattern)
they
version
with
params,
can't
drift,
and
need
no
join.
In
v1
the
table
would
have
been
empty
anyway
(D5:
no
non-unit
windows
ship).
liquidity_epoch_settlements
→
derived,
not
stored.
p_daily = P/n
(params);
retained = P_daily − Σ accruals.accrued_usd
(the
above-cap
residue
+
floor
residue
falls
out);
paid/forfeited
totals
=
sum
accruals
by
status.
Every
roll-up
field
is
a
query
over
mmlp.liquidity_programs
+
accruals,
so
the
conservation
invariant
computes
it
and
admin
gets
it
from
a
view.
Storing
all
computed
traders
(including
FORFEITED
sub-threshold
rows)
makes
accruals
the
single
conservation
source;
retained
—
the
only
non-per-account
dollar
—
is
P_daily − Σaccrued_usd.
mmlp.liquidity_snapshots
trimmed:
drop
s_bids/s_asks/n_share
(intermediates
/
derivable).
Rows
are
stored
for
participating
accounts
so
zero-score
participation
remains
distinguishable
from
no
participation.
Matters
because
it
is
the
highest-volume
table.
Not
folded:
liquidity_payouts
into
liquidity_accruals.
They
are
1:1
and
a
status
compare-and-swap
(UPDATE … WHERE status='APPROVED')
would
give
exactly-once
credit
with
the
immutable
audit
relocated
to
the
transaction-engine
transactions
ledger
—
dropping
Part
2
to
zero
new
tables
(total
three).
Rejected
for
v1
because
D7
wants
the
credit
path
built
as
shared
partner-rebate
plumbing,
and
matching
that
RFC's
separate
append-only
idempotency
ledger
keeps
the
two
programs
on
one
code
path.
Revisit
if
the
credit
path
turns
out
not
to
be
shared.
Most
tunables
are
per-product
in
the
DB
(P,
v,
q_min,
B(t))
—
a
params
edit,
not
a
deploy.
Global
engine
knobs
in
recon-engine
config
(rs/recon-engine/src/config.rs,
alongside
FeeRatesConfig):
| Knob | Default | Notes |
|---|---|---|
| snapshot cadence | 60 s | one random instant per minute per symbol |
| epoch boundary | 15:00 US/Central | diverges
from
the
fee
engine's
UTC-daily
cadence
—
needs
a
Central-time
epoch
helper;
DST-aware
(Central
shifts
vs.
UTC
across
the
year).
Store
epoch_start_ts/epoch_end_ts
on
written
rows
so
historical
epochs
stay
self-describing
if
the
configured
boundary
changes.
Reuse/extend
calendar_math.rs. |
| anti-concentration cap | 0.40 | spec-fixed; config only for tests |
| min-payout formula | min($10, 0.01 × P_daily) |
spec-fixed |
| settlement precision | floor-to-cent (2 dp) | ledger stores full precision; credit floors |
| staleness / reconstruction lookback | (from D4 verification) | how
far
back
order_log
is
scanned
to
establish
resting
state
at
t |
| dry-run mode | on (rollout) | compute
+
write
scores/ledger
rows
with
outcome
set,
but
do
not
credit
—
reconcile
before
flipping
live |
Per-product
enrollment:
a
symbol
is
in
the
program
iff
it
has
a
mmlp.liquidity_programs
row
with
the
greatest
effective_at <= now
—
absent
⇒
invisible
to
the
snapshotter
(mirrors
the
fee
engine's
WHERE fee_mode='auto'
gating
and
the
auto-band
config absent ⇒ skip
pattern).
| Component | Part | Change |
|---|---|---|
rs/sdk-internal/src/liquidity_program/{score,settle}.rs |
1 | new — the pure scorer + waterfall + gates (shared, SDK-clean) |
rs/sdk-internal/src/liquidity_program/params.rs |
1 | new
—
ProgramParams,
BonusSchedule |
rs/recon-engine/src/liquidity/snapshotter.rs |
1 | new
—
1-min
tick,
book
reconstruction
from
order_log,
mark,
calls
scorer,
appends
mmlp.liquidity_snapshots |
rs/recon-engine/src/liquidity/settle.rs |
1 | new
—
epoch-close
aggregation,
waterfall,
gates
→
writes
accrual_engine.liquidity_accruals
(no
cash) |
rs/recon-engine/src/checks/liquidity_conservation.rs |
1 | new
InvariantCheck
—
accrued + forfeited + retained == P_daily
per
settled
epoch |
rs/sdk-internal/db/src/entities.rs |
1 | new
DbLiquidityProgram
(w/
BonusWindow
serde
type),
DbLiquidityAccrual
(Part
1);
DbLiquidityPayout
(Part
2) |
db/postgres/1.sql |
1 | 2
new
Postgres
tables
(mmlp.liquidity_programs
w/
bonus_windows JSONB,
accrual_engine.liquidity_accruals) |
db/clickhouse/init.sql |
1 | mmlp.liquidity_snapshots
(ReplacingMergeTree(computed_at)) |
rs/api-gateway/src/public_routes.rs
+
rs/sdk/src/types/trading.rs |
1 | publish
P,
v,
q_min,
B(t)
on
/instruments
/
Instrument
(SDK-clean) |
rs/api-gateway/src/admin_routes.rs |
1 | admin
CRUD
for
params
+
bonus
windows
(mirror
fee-schedules) |
gui/apps/admin/src/pages/liquidity-program/ |
1 | new
admin
params
page
(mirror
FeePrograms.tsx)
+
minimal
accrued
read-out |
db/clickhouse/init.sql
order_log |
1 | possibly enriched with partial-fill resting-size deltas (D4 verification outcome) |
db/postgres/1.sql
liquidity_payouts |
2 | idempotent payout ledger + freeze trigger |
rs/transaction-engine |
2 | new
system
account
AX_LIQUIDITY_PROGRAM_ACCOUNT_ID
(or
reuse
AX_FEE_ACCOUNT_ID,
Q4);
no
logic
change
(reuse
Deposit) |
rs/api-gateway/src/admin_routes.rs |
2 | POST /admin/liquidity/approve
+
pay
(human-gated
credit) |
| generic program-payouts module | 2 | Part
2
=
an
adapter
(config
row
+
enqueue
program_payout_requests);
the
shared
sweep
credits,
after
tax/currency/approval
clear.
No
liquidity-specific
credit
code. |
gui
participant
surfaces |
ff | fast-follow — full rewards dashboard + on-book qualifying-band highlight (D6) |
mmlp.liquidity_programs
row
is
written.
mmlp.liquidity_programs
for
effective_at = '2026-09-01T00:00:00Z'
(symbols
+
pools
+
quote
params)
via
a
one-off
db/postgres/*.sql
data
migration
(per
the
CLAUDE.md
convention
—
declarative
schema
in
1.sql,
data
backfill
in
a
separate
file).
order_log
enrichment,
that
is
a
ClickHouse
schema
addition
(nullable
columns
/
a
sibling
stream)
—
additive,
no
rewrite.
Per the house insta-inline convention and the lifecycle-path rule.
Pure-core snapshots (re-derived, not copied from the PDF — D2):
score_snapshot
against
re-derived
Example
1
(Alice 0.8 / Bob 0.2)
and
Example
2
edge
cases:
Eve
(sub-q_min
→
non-scoring
but
still
moves
the
mark),
Frank
(d = v = 25
inclusive
→
w = 0,
and
a
zero
side
zeroes
S_total),
Grace
(outside
band
→
0),
Hana
(sole
positive
→
N = 1).
d = v
weights
exactly
0
(inclusive);
d = v + ε
non-scoring;
d = 0
weights
1.
S_total = 0;
asymmetric
sides
⇒
min.
q_min
gate
per
D1
(per-order):
with
q_min = 10,
two
6-lot
orders
at
the
same
(side,
price)
each
fail
individually
→
that
side
scores
0
(splitting
below
q_min
forfeits);
a
single
10-lot
at
the
same
price
qualifies,
and
a
4-lot
beside
it
drops
from
the
sum.
Pin
so
the
per-order
rule
cannot
silently
regress.
None.
cap_waterfall:
re-derived
Example
3
two-round
waterfall
(Alice/Bob → $66.67,
Carol → $33.33,
conserves
to
$166.67);
Example
6
solo
maker
(capped
at
40%,
$100
retained);
Example
4
two-maker
(both
capped,
$33.33
retained)
with
B(t)=2
weighting
(Alice 4/7,
Bob 3/7).
Assert
the
three
Appendix-A
properties
as
generative/property
tests:
bounded
(≤
n
rounds),
order-independent
(shuffle
input
→
identical
output),
conserving
(Σ allocations + retained == pool,
no
allocation
> C).
and pays; Tess's $1.33 forfeits (not redistributed, no rollover);$56.666…
→
$56.66`
floor.
Integration / lifecycle (CLAUDE.md rule — the snapshotter reads live-ish book state):
order_log:
an
order
placed
before
t
and
cancelled
after
t
is
resting
at
t;
cancelled
before
t
is
not;
partially
filled
before
t
rests
at
the
reduced
size
(this
is
the
D4
precision
test
—
it
will
fail
until
D4
is
resolved,
and
that
failure
is
the
signal).
epoch_start_ts/epoch_end_ts
pairs.
liquidity_conservation
check
green
over
a
settled
day
(paid + forfeited + retained == P_daily;
ledger
squares
with
credited
transactions).
Sequenced against Sept 1. Part 1 alone satisfies the Sept 1 promise (scoring live, params published, accruals visible). Part 2 runs on its own clock behind the Finance/Legal gates and has no hard date.
Part 1 — scoring & accrual (targets Sept 1):
order_log
verification.
(D1
is
settled
—
the
engine
gates
per-order,
matching
the
PDF;
no
doc
correction
owed.)
Gate
everything;
do
it
first.
sdk-internal
scorer
+
waterfall
+
gates)
with
the
full
re-derived
snapshot
suite.
No
I/O,
no
deploy
risk
—
correctness
is
won
here.
mmlp.liquidity_programs
w/
bonus_windows JSONB,
accrual_engine.liquidity_accruals,
ClickHouse
mmlp.liquidity_snapshots
+
Appendix
B
2026-09
rows).
Deploy
dark.
market_snapshots.
Validates
D4
in
prod
shape.
accrual_engine.liquidity_accruals
+
conservation
invariant.
This
"goes
live"
without
moving
a
cent
—
it
is
safe
to
enable
broadly
early.
Reconcile
a
full
week;
eyeball
combined-take
(D3
metric).
Part 2 — payout & credit (optional, no hard date, D7):
POST /admin/liquidity/approve
+
a
manual
pay
over
one
closed
period
on
one
low-pool
Bootstrap
symbol
(3, 500/mo⇒ 45/day
cap).
Soak,
watch
liquidity_payouts
+
credited
balances
+
the
ledger-squares-with-transactions
check.
Fast-follow
(either
part):
full
rewards
dashboard
+
on-book
highlight
(D6),
B(t)
window
admin
(D5),
combined-take
cap
(D3)
only
if
the
metric
shows
abuse.
Decisions still needing a human sign-off
order_log
decrement
remaining_quantity
on
partial
fills,
or
only
emit
Pending
+
terminal
rows?
Determines
whether
Option
B
works
as-is,
needs
order_log
enrichment,
or
falls
back
to
a
live
snapshotter.
Nothing
downstream
is
safe
to
build
until
this
is
answered.
q_min
per-order,
matching
the
published
PDF
§3.1;
no
doc
correction
owed.
Behavior-neutral
at
q_min = 1;
splitting-forfeits
semantics
only
apply
once
a
product
publishes
q_min ≥ 2.
AX_LIQUIDITY_PROGRAM_ACCOUNT_ID
(clean
P&L
attribution,
needs
a
funding
process)
or
from
AX_FEE_ACCOUNT_ID
(fee
revenue
funds
rewards,
no
new
account)?
Affects
accounting,
not
mechanism.
Risks / adversarial surface
q_min
orders.
The
mark
is
the
raw
best
bid/offer
(spec
§3.1),
so
a
sub-q_min
order
that
never
scores
can
still
move
the
reference
price
(the
spec
itself
flags
this
in
Example
2,
Eve)
—
shifting
every
other
order's
d
and
thus
the
whole
snapshot's
scores.
v1
follows
the
spec
(raw
best).
Mitigation
to
consider
for
v2:
define
the
mark
on
best
qualifying
(≥
q_min)
prices,
or
clamp
mark
moves
between
adjacent
snapshots.
Flag
now;
don't
fix
in
v1
without
a
spec
change.
order_log
is
35
point-in-time
queries/min.
Cheap
if
order_log
is
indexed
for
it,
but
confirm
the
query
plan
(the
ORDER BY (event_timestamp_ns, order_id)
primary
key
favors
time-range
scans;
per-symbol
filtering
may
want
the
existing
bloom-filter
index
on
order_id
to
not
be
the
access
path).
Batch
per
minute,
one
scan
across
symbols.
Per
CLAUDE.md,
run
the
linear-workflow
skill
and
confirm
before
creating
anything.
Proposed
shape:
one
epic,
two
sub-epics
(Part
1
/
Part
2)
so
Part
2
can
be
scheduled
independently.
Part 1 — Scoring & accrual (v1): (status as of 2026-08-12 — see Implementation status)
ax-mmlp2
(#3533
/
A-4496),
not
the
proposed
sdk-internal/src/liquidity_program/
path.
mmlp.liquidity_programs
w/
bonus_windows JSONB,
accrual_engine.liquidity_accruals,
CH
mmlp.liquidity_snapshots).
✅
Schema
+
entities
shipped
(#3470,
#3531
/
A-4493);
Appendix-B
2026-09
backfill
via
admin-CLI
materialize
(#3560
/
A-4498).
Confirm
production
rows
exist.
accrual_engine.liquidity_accruals,
no
cash
+
conservation
check.
⚠️
Settler
shipped
(#3539
/
A-4497,
batched
#3587
/
A-4610);
snapshotter
NOT
built
(the
blocking
gap
—
no
producer
for
liquidity_snapshots);
conservation
InvariantCheck
not
confirmed
present.
Part 2 — Payout & credit (optional, gated): none built — blocked on the program-payouts module (also unbuilt) and the Finance/Legal gates.
liquidity_payouts
ledger
+
double-entry
Deposit
+
POST /admin/liquidity/approve/pay
(human-gated).
Fast-follow: none built.
B(t)
window
admin
+
combined-take
metric.
Remaining
critical
path
to
a
live
Part
1:
(1)
resolve
Q1
/
D4
(order_log
partial-fill
precision);
(2)
build
the
snapshotter/book-reconstruction
producer
that
fills
mmlp.liquidity_snapshots;
(3)
add
the
conservation
InvariantCheck;
(4)
wire
real
admin
HTTP
CRUD
behind
the
GUI
shell
and
publish
params
on
/instruments.
Only
then
does
the
shipped
settler
produce
non-empty
accruals.
| Concern | Location |
|---|---|
| Order lifecycle event stream (book reconstruction, D4) | db/clickhouse/init.sql:83-115
(order_log;
caveat
:67-81) |
| Best bid/offer sampler (mark cross-check) | db/clickhouse/init.sql:319-336
(market_snapshots) |
| recon-engine check framework / context / scheduler | rs/recon-engine/src/check.rs:14-20,51-71;
rs/recon-engine/src/lib.rs:519-618;
incident_io.rs |
| Fee-engine periodic task (loop template) | rs/recon-engine/src/fees.rs;
config
rs/recon-engine/src/config.rs |
| Trailing-window accumulator sibling | PR
#3389,
branch
incremental-volumering-fee-tiering
(rs/recon-engine/src/volume_ring.rs) |
| USD credit path / money type | rs/transaction-engine/src/lib.rs:83,239,264,333;
double-entry
rs/transaction-engine/src/lending.rs:23-70 |
| Balance / transaction ledgers | db/postgres/1.sql:166-174
(current_balances);
db/clickhouse/init.sql:288-317
(transactions) |
| Idempotent-payout precedent | db/postgres/1.sql:1475-1510
(treasury_engine.deposit_credit_attempts);
partner-rebate-payouts |
| System fee account | rs/sdk-internal/src/account_id.rs:10
(AX_FEE_ACCOUNT_ID) |
| Public instruments endpoint / SDK type | rs/api-gateway/src/public_routes.rs:204-264;
rs/sdk/src/types/trading.rs:31-84 |
| Typed discriminator enum pattern | rs/sdk/src/types/trading.rs:138-162
(InstrumentCategory);
rs/sdk-internal/src/fee_program.rs:16-35
(FeeWindowShape) |
| Per-instrument PgJson config pattern | rs/sdk-internal/db/src/entities.rs:709-759
(DbInstrument) |
| Fee-program admin surface (template) | gui/apps/admin/src/pages/fee-programs/FeePrograms.tsx;
rs/api-gateway/src/admin_routes.rs:2464-2527;
DbFeeSchedule
rs/sdk-internal/db/src/entities.rs:6838-6915 |
| Mark / band infra (parity check) | price-band-automation |
| Fee-engine plan (additive sibling) | A-4094 (shipped; plan retired) |
Spec:
customer
notice
"AX
Liquidity
Program
—
Effective
September
1,
2026"
(Appendix
A
cap
waterfall,
Appendix
B
pools).
Examples
1
and
3
independently
re-derived
in
this
RFC
and
confirmed
consistent
with
the
formulas;
all
other
tables
to
be
re-derived,
not
trusted,
per
D2.
Code
references
as
of
branch
calgary.