adr: 0017 title: "AMM permanent price impact — trades leave a decaying footprint" status: accepted implementation-status: implemented date: 2026-06-03 implemented-date: 2026-06-03 implemented-in: - ftl-backend feat/amm-permanent-impact deciders: [@amalkrsihna] affects-specs: [amm-pricing, cfd-pricing-and-wallet] affects-code: - ftl-backend/internal/redis/lua/position_open.lua - ftl-backend/internal/redis/lua/position_close.lua - ftl-backend/internal/instrument/pricing.go - ftl-backend/internal/instrument/service.go - ftl-backend/internal/sportmonks/noise.go - ftl-backend/internal/sportmonks/postmatch.go - ftl-backend/internal/flusher/flusher.go - ftl-backend/migrations/000038_add_permanent_impact.up.sql - ftl-backend/migrations/000039_bump_k_mod_default.up.sql supersedes: null superseded-by: null
ADR-0017: AMM permanent price impact — trades leave a decaying footprint¶
Context¶
Play-testing showed the price chart "snaps back" to its base range after every Buy or Sell is closed — a completed trade left no lasting mark. Three causes compounded:
- Closes exactly reverse opens. The CFD price is
base_price + k_mod · net_position_imbalance.position_open.luaaddssign · lot_sharesto the imbalance;position_close.luasubtracts exactly the same amount. A buy→close round-trip returns the imbalance — and the price — to its starting point. There is no memory of the trade. k_modwas too small to see. At the default0.01, one lot (CONTRACT_SIZE = 5shares) moved price by0.05on a ₹100–500 instrument — below the chart's resolution. Even 100 lots moved it ~₹5.- Noise ignored the AMM. The synthetic-noise loop anchored its jitter on
live_base_price/base_priceand ignorednet_position_imbalanceentirely, so between ticks the chart was visually pulled back to base even while a position was open.
The product intent is that buying pushes a player's price up and it stays up (with minimal decay), and selling pushes it down — a real, demand-driven market.
Decision¶
Introduce a per-instrument permanent_impact accumulator that records a
fraction of every closed trade's directional pressure and enters the price
formula alongside the live imbalance:
Concrete changes:
- Schema. Migration 000038 adds
instruments.permanent_impact NUMERIC(14,4) NOT NULL DEFAULT 0(additive). Migration 000039 bumps thek_moddefault0.01 → 0.05and migrates rows still on the old default, so one lot now moves price ~₹0.25 — visible against the 0.2 % noise band. - Accumulate on close.
position_close.luaaddssign · lot_shares · PERM_FRACTION(PERM_FRACTION = 0.30) topermanent_impact, clamped by a floor of-(base_price/k_mod)·0.5 − new_imbalanceso a heavy net-short can never drive the combined term — and thus the price — to or below zero. - Price everywhere reads it.
position_open.lua(open quote + post-open price),position_close.lua(close price + equity walk), the Go instrument enrichment (ComputeCFDPrice), and the FT snapshot inpostmatch.goall usek_mod · (imbalance + permanent_impact). - Noise anchors on the AMM price.
noise.goreadsnet_position_imbalance,k_mod,permanent_impactand oscillates its jitter aroundbase + k_mod·(imbalance + permanent_impact)instead of raw base. - Durable + decaying. The flusher persists
permanent_impact(andnet_position_imbalance) to PG so the footprint survives a cold restart, and runs a decay pass everyPERM_IMPACT_DECAY_EVERY_Nflusher ticks shrinking each instrument's impact byPERM_IMPACT_DECAY_RATE(defaults: 600 ticks ≈ 60 s, 0.5 %/pass ⇒ ~2.3 h half-life), flooring to exactly 0 near zero. - Reset at full-time.
postmatch.goclearspermanent_impactto 0 in Redis and PG, because the newbase_pricealready folds in the match outcome via EWMA — carrying old trade pressure forward would double-count.
Consequences¶
- Positive: A closed trade leaves a visible, lasting footprint (e.g. one long
lot retains
k_mod · lot_shares · 0.30 = 0.05 · 5 · 0.30 = ₹0.075after close) that decays over hours. The chart reflects accumulated demand instead of reverting instantly. - Positive: The noise no longer fights the AMM — jitter rides on top of the trade-shifted price.
- Positive: All tuning levers (
PERM_FRACTIONin Lua,k_moddefault, decay cadence/rate via env) are adjustable without structural change. - Risk — k_mod retiming: open positions are priced from
k_modread at close time, so bumping the default changes the unrealized PnL of any already-open position on the next equity walk. Mitigation: run migration 000039 between matches when imbalance ≈ 0 and few positions are open. - Risk — flusher write expansion: the instrument UPDATE now also writes
net_position_imbalance+permanent_impact; a Redis hash eviction would reset live imbalance to 0 on the next flush. This risk already existed for wallets and is accepted for the tournament scope.
Alternatives considered¶
Alternative A: Don't reverse imbalance on close (leave the open's push in place)¶
Make position_close.lua skip the imbalance decrement so the open's price impact
simply stays.
Rejected. The imbalance must reflect currently open interest for margin and
equity math to be correct — leaving a closed position's shares in
net_position_imbalance would corrupt the unrealized-PnL walk for everyone else
holding that instrument. A separate permanent_impact field keeps live margin
accounting honest while still recording a footprint.
Alternative B: Just raise k_mod and accept the snap-back¶
Bump k_mod so trades are visible, but keep closes fully reversing.
Rejected. A bigger k_mod makes the open visible but the close still erases
it — the chart would jump up on buy and snap back on close, which is exactly the
"trades leave no trace" complaint, only louder. Permanent impact is what gives the
chart memory; the k_mod bump only makes that memory legible.
Alternative C: Never decay permanent_impact¶
Let the footprint persist indefinitely.
Rejected. Without decay, months of trading would warp base prices arbitrarily far from the player's actual form-driven value, and the FT EWMA (which already encodes performance) would be swamped. A multi-hour half-life plus the FT reset keeps the footprint meaningful in-session without permanent distortion.
Verification¶
- Unit:
ComputeCFDPriceformula table; a buy→close round-trip leaves the price strictly above the pre-trade base byk_mod · lot_shares · PERM_FRACTION; flusher decay shrinks toward 0, floors to exactly 0, and re-queues dirty instruments (miniredis). - Migrations applied on dev02:
permanent_impactcolumn present,k_moddefault = 0.05, 780 rows migrated off 0.01.go build/vet/gofmtclean. - Live buy→close→footprint integration is covered by the unit tests plus the
already-proven imbalance-reversal mechanism (this session validated that
open/close move
net_position_imbalancecorrectly); a full live-window run was deferred because no real Sportmonks match was live and the local replay match has no tradeable instruments for its teams.