T_LU
New York

Work

Flipping Engine

A pricing model for the Old School RuneScape economy. It scores itself against what actually happened.

The problem

The tool most people use for this is closed source. It emits suggestions and gives no account of why, which makes it impossible to tell a good call from a lucky one. The point of rebuilding it was to end up with something whose reasoning could be inspected and, more usefully, disproved.

The naive version of the problem is a trap. Take the gap between what buyers pay and what sellers accept, subtract tax, rank by the result, and the top of that list is almost entirely items whose spread exists only because the price is moving. A crash makes a stale high look like profit, a spike makes a low look cheap. A ranker with no defence against that will confidently recommend catching a falling knife.

The data adds its own constraints. The upstream API asks for bulk endpoints and a descriptive user agent, so per-item polling is out. Prices arrive in five-minute buckets, which sets a floor on how finely anything can be measured.

The engineering decision

How should the engine price a flip, and how would it know if it was wrong?

Constraints

  • Bulk endpoints only, with no per-item polling of the price API.
  • Five-minute granularity is the finest measurement available.
  • Every ranking rule has to be checkable against history, or it is just an opinion with arithmetic attached.

Options considered

  • Quote one step inside the current best prices What v1 did. Simple, and it maximises the spread on paper. But it assumes a fill the moment the market touches your price, which turned out to be the assumption doing all the damage.
  • Cut the assumed fill rate until the backtest agrees Tried, and it worked in the sense that the numbers matched. It also hid the mechanism: one constant was absorbing an effect that had nothing to do with it.
  • Price at the edges of the recent distribution and model the waiting explicitly Chosen. Quote near the 25th and 75th percentiles of recent lows and highs, and carry a separate touch-fraction term for how often price actually reaches the quote.

Decision

Pricing and waiting are two different things and now have two different terms. The spread comes from percentile pricing; the delay comes from a touch fraction. Collapsing both into one capture constant is what made the earlier model unreadable.

Implementation

Ingest writes append-only snapshots every five minutes. A stability gate rejects candidates whose entry or exit sits more than two standard deviations against them in the last 24 hours, with a floor on the deviation so that ultra-stable items do not produce huge z-scores from one-gp noise. A downtrend guard rejects buys into a falling mid-price. The backtest replays the ranker against recorded history and reports fill rate and round-trip time.

Result

Every rejection carries a reason and every constant has a measurement behind it. The measurement does not currently agree with the model, and the project says so, and does not quote the one figure that once agreed.

Architecture

  1. Wiki price API · bulk endpoints
  2. Ingest · every 5 minutes via launchd
  3. SQLite · append-only snapshots
  4. Stability gate · z-score vs 24h distribution
  5. Ranker · percentile pricing + touch fractions
  6. Backtest · replay against recorded history
  7. Calibration · constants updated, or explained

The loop back from calibration is the whole point. Ingest, gate and rank would be an opinion; the backtest is what makes it falsifiable.

The calibration loop that could not close

The first backtest was unambiguous: real round trips took far longer than the ranker predicted. The obvious response was to cut the constant governing how much of an hour’s flow an offer captures, from 0.10 to 0.03, and the median ratio of actual to predicted came out at 1.0. Calibrated, or so it looked.

That was the wrong fix. Two effects were folded into one number: how much volume an offer captures once price reaches it, and how long price takes to get there. Most of the delay was the second. So v3 separated them, priced at the edges of the recent distribution, and the constant went back to 0.10.

Then the database turned out to be corrupt. One table had duplicate b-tree pages and could not be scanned, and the ranker selects candidates partly from that table. On the repaired database the same measurement reads 0.68–0.75, not 1.0. The earlier figure had been a coincidence measured on damaged data.

Re-deriving the constant then exposed something worse, in the harness rather than the model. The backtest’s capture flag feeds only the simulated actual; the prediction always read a module constant. The flag moved one side of the ratio and left the other fixed, so sweeping it pushed the number away from 1.0. The instruction written in the code, multiply the constant by this ratio to calibrate, described something the harness could not do.

With both sides finally swept together the ratio plateaus near 0.7 and barely responds to the constant at all. No value drives it to 1. So the residual thirty percent over-prediction is not a capture problem either, and the honest summary is that this project measures its own predictions and cannot yet correct them. The loop is half-built. It is written down that way because a measurement that only ever agrees with you is worth nothing.

Evidence

  • 4,667 items tracked two months of five-minute history
  • 0.68–0.75 actual vs predicted where the loop currently sits, not 1.0
  • 51 tests passing over tax, ranking, stability and backtest

Those figures come from a repaired database: the original had duplicate b-tree pages, one table was unscannable, and recovery lost 87 rows of 73 million. Retention now keeps it at 893 MB rather than 6.3 GB. The tax model is the unglamorous half and matters more than the ranking: 2% floor-rounded, capped, free under a threshold, with an exemption list. Get it wrong and every margin above it is wrong too, which is why it has its own test module.

What I would improve

  • The calibration loop does not close. The ratio sits near 0.7 and is insensitive to the constant meant to control it, so the remaining suspects are the touch-fraction term, the minimum round-trip floor, and the simulation’s bucket discretisation. Until that is resolved the ranker is knowingly pessimistic about timing.
  • The tracker that would log real fills has recorded none. Every constant is therefore calibrated against replayed history rather than against what actually happened, and replay cannot see the orders that never filled at all.
  • The backtest window is roughly thirty hours per item. Long enough to measure a round trip, far too short to say anything about a market regime.
  • Ranking is heuristic. There is no position sizing worth the name, and no model of how the engine’s own orders would move prices if the size were meaningful.
  • The snapshot database has grown past six gigabytes with no retention policy, and it needs one before it needs anything else.