# How to backtest a CS2 skin trading strategy

> Backtest a CS2 skin trading strategy on point-in-time bid and ask history, with fees, gaps, and no lookahead bias. Runnable data-pull code included.

*History & backtesting · Advanced · Updated 2026-09-25*

A backtest replays a trading rule over past prices to estimate how it would have done. It is only as honest as its assumptions. The three that most often turn a losing CS2 strategy into a paper winner are buying at an average price instead of the ask, deciding on data that was not available yet, and ignoring fees and the time an item cannot be moved after you buy it.

SkinPricer's history endpoints give you both sides of the book per time bucket, per market or across the market, which is what an honest backtest needs. This guide shows how to pull the right series, how to line up decisions and fills in time, and how to model costs and gaps. The strategy code is a deliberately simple example; the structure around it is the part to reuse.

## Choose the series that matches your execution

| You need | Use |
| --- | --- |
| The price you could buy at | `bestAskCents` from [price history](/api-docs/history). Not `avgPrice` or `medianPrice`: you cannot buy at the average. |
| The price you could sell into immediately | `bestBidCents` from the same buckets. In the aggregate series it is the best cash-executable bid that does not exceed the bucket's ask. |
| Which venue you would actually trade on | [History by market](/api-docs/history-markets), one series per venue, filtered with `markets`. |
| Evidence that trades happened | `listingType=SALE_HISTORY`, whose `volume` is a trailing-window sale count (see the warning below). |
| A float band or Doppler phase | [Attribute price history](/api-docs/attribute-history), with open, high, low, and close asks per bucket. |

## Step 1: pull point-in-time history

Pin the bucket size with `interval` so every item in your test uses the same clock. Hourly buckets are a good default for skins: fine enough to model entries and exits, coarse enough that 90 days of one item is about 2,160 points. History requires the Pro plan, and a single request is capped at 10,000 points, counting one series per market on the per-market endpoint.

**curl**

```bash
export SKINPRICER_API_KEY="sp_live_•••"   # from your account's API keys page

curl -s "https://pricing.skinpricer.com/v1/pricing/AK-47%20%7C%20Redline%20(Field-Tested)/history?interval=HOUR_1&from=2026-06-27T00:00:00Z" \
  -H "Authorization: ApiKey $SKINPRICER_API_KEY"
```

**Python**

```python
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import quote

import requests

API = "https://pricing.skinpricer.com/v1"
HEADERS = {"Authorization": f"ApiKey {os.environ['SKINPRICER_API_KEY']}"}


def hourly_history(name: str, days: int = 90) -> list[dict]:
    since = datetime.now(timezone.utc) - timedelta(days=days)
    res = requests.get(
        f"{API}/pricing/{quote(name, safe='')}/history",
        params={"interval": "HOUR_1", "from": since.isoformat()},
        headers=HEADERS,
        timeout=10,
    )
    res.raise_for_status()
    data = res.json()
    if data.get("effectiveInterval") != "1h":
        raise ValueError(f"unexpected bucket size: {data.get('effectiveInterval')}")
    return data["history"]


buckets = hourly_history("AK-47 | Redline (Field-Tested)")
```

**Node (SDK)**

```ts
// npm i @skinpricer/sdk
import { SkinpricerClient } from "@skinpricer/sdk";

const client = new SkinpricerClient({
  apiKey: process.env.SKINPRICER_API_KEY!,
  authScheme: "ApiKey",
});

// One series per venue you could actually trade on.
const perVenue = await client.history.byMarket("AK-47 | Redline (Field-Tested)", {
  markets: ["buff163", "csfloat", "skinport"],
  interval: "HOUR_1",
  from: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
});

for (const { market, history } of perVenue.series) {
  console.log(market, history.length, "hourly buckets");
}
```

*Store the raw buckets as you receive them. Re-deriving a test from stored data is reproducible; re-downloading it later may not be.*

## Step 2: line up decisions and fills in time

Each bucket's `timestamp` is the start of its window. The bucket only describes the market once the window has closed, so a rule evaluated on bucket `i` can only trade in bucket `i + 1` at the earliest, and at that bucket's prices, not bucket `i`'s. This one rule removes most accidental lookahead.

```python
def backtest(buckets, lookback=168, buy_fee=0.0, sell_fee=0.05):
    """Toy mean reversion: buy 10% under the trailing mean ask, sell once
    the bid covers cost plus 5%. Decide on bucket i, fill on bucket i + 1."""
    pnl, entry = [], None
    for i in range(lookback, len(buckets) - 1):
        now, nxt = buckets[i], buckets[i + 1]
        past = [b["bestAskCents"] for b in buckets[i - lookback : i + 1]
                if b["bestAskCents"] is not None]
        if len(past) < lookback // 2:
            continue  # too many gaps to trust the mean
        mean_ask = sum(past) / len(past)

        if entry is None:
            signal = now["bestAskCents"] is not None and now["bestAskCents"] < 0.9 * mean_ask
            if signal and nxt["bestAskCents"] is not None:
                entry = nxt["bestAskCents"] * (1 + buy_fee)      # pay the next ask
        else:
            bid = now["bestBidCents"]
            if bid is not None and bid * (1 - sell_fee) >= entry * 1.05 and nxt["bestBidCents"] is not None:
                pnl.append(nxt["bestBidCents"] * (1 - sell_fee) - entry)  # hit the next bid
                entry = None
    return pnl


trades = backtest(buckets)
print(len(trades), "round trips, net", sum(trades) / 100, "USD")
```

*The fees are placeholders. Use each venue's real buyer and seller fees; the markets directory lists them per venue.*

> **The newest bucket is provisional:** Quotes arrive with some ingest latency, so the most recent bucket can still be filling when you read it. History you download later includes those late quotes, which a live system would not have seen in time. In live trading, treat the current bucket as incomplete and act on the last closed one.

## Step 3: model the costs you would really pay

- Buy at the ask and sell at the bid. A strategy that only works on `medianPrice` has not found an edge; it has found the spread.
- Apply each venue's buyer fee, seller commission, and any withdrawal cost separately. Percentage fees and fixed fees behave very differently on cheap items.
- Model the holding period. Many items cannot be moved or resold for a while after purchase, so an exit signal during that window cannot be acted on.
- Steam Community Market prices settle in Steam wallet credit, not cash. Keep them out of cash P&L unless you model the conversion; the [Steam vs cash-market guide](/resources/steam-vs-cash-market-prices) explains why.
- Size matters. A best ask is often a single listing; if your rule trades more than one unit, check depth rather than assuming the whole order fills at the top of book.

## Step 4: handle gaps and nulls explicitly

Real market history has holes. A missing bucket means nothing was observed in that window, and a `null` side means no quote existed on that side. Neither is a price. Do not trade on a bucket with a missing side, and do not forward-fill an old ask across a long gap as if it were still available. Log how many decisions were skipped for missing data; if it is a large share of your signals, the result says more about coverage than about the strategy.

> **Sale volume overlaps between buckets:** With `listingType=SALE_HISTORY`, each bucket's `volume` counts sales in the trailing `salesWindow` (24h, 7d, 30d, or 90d) as of that bucket. Adjacent buckets share most of their window, so summing them multiplies the real volume. Use one bucket's value as a liquidity filter, never a running total.

## Testing float-band and phase strategies

Strategies on low floats or Doppler phases need band-level data. [Attribute price history](/api-docs/attribute-history) returns, per bucket id, `openAskCents`, `highAskCents`, `lowAskCents`, `closeAskCents`, and `closeBidCents` at `10m`, `1h`, or `1d` spacing, with the same point-in-time caveats. Use `closeAskCents` of bucket `i + 1` as your fill, not the `lowAskCents` of bucket `i`, which you could only have known in hindsight. Windows are capped at 10,000 buckets, so run long tests at `1d`.

Watch for survivorship bias too. If you choose today's popular items and test them over the past quarter, you are testing items that already turned out to be liquid. Pick your universe with rules you could have applied at the start of the test.

## When to reach for a different endpoint

To explore a series visually before you test it, start with the [price history guide](/resources/cs2-price-history). To turn a signal into an estimate of what an item is worth right now, see [estimating fair value](/resources/cs2-skin-fair-value). And if your strategy trades float or phase premiums, [BUFF163 prices and history](/resources/buff163-prices-and-history) covers the bucketed series.

## Endpoints used

- [GET /v1/pricing/:marketHashName/history](/api-docs/history.md)
- [GET /v1/pricing/:marketHashName/history/markets](/api-docs/history-markets.md)
- [GET /v1/attribute-prices/history](/api-docs/attribute-history.md)

## Related guides

- [How to get CS2 skin price history at any interval](/resources/cs2-price-history.md)
- [How to estimate what a CS2 skin is really worth](/resources/cs2-skin-fair-value.md)
- [How to get BUFF163 prices, float buckets, and history in USD](/resources/buff163-prices-and-history.md)
