A new contract appears on an exchange, the first minutes move in every direction, and then part of the market remembers risk management. That short post-listing window became the focus of this case study.

The task was standard for any product with market logic: formalize the hypothesis, collect the data, build a simulator, run the parameters, and check whether a stable edge exists. In this case, the focus was short trades on confirmed Bybit listing events with fixed risk and explicit TP/SL logic.

If a strategy does not survive a simple backtest, it has little chance of surviving in an environment where decisions move at product speed rather than presentation speed.

For the test, I used ten confirmed listing events for USDT contracts. For each run, I loaded a one-minute data window spanning 18 hours before the listing and 10 hours after it. The pre-listing segment is stored as context for future pattern analysis, while the post-listing segment is used directly in the trade simulation. The data came through the Bybit V5 Market Kline endpoint, which returns historical candles for the requested symbol, interval, and time range. [web:133]

The listing moment is defined as entry_dt, while the data frame is bounded by start_dt and end_dt. This gives the model useful context before the event and a complete post-event slice.

Code Data window around the listing
for symbol, entry_str in allpairs:
    entry_dt = parse_entry_dt(entry_str)
    start_dt = entry_dt - timedelta(hours=18)
    end_dt = entry_dt + timedelta(hours=10)

    df = get_klines(symbol, start_dt, end_dt, category="linear", interval="1")

The candle-loading function converts timestamps into milliseconds, calls the API, and returns a sorted DataFrame. No synthetic inputs, only what exists in the instrument’s historical record. [web:133]

Code Retrieving historical kline data
def get_klines(symbol, start_dt, end_dt, category="linear", interval="1"):
    start_ts = int(start_dt.timestamp() * 1000)
    end_ts = int(end_dt.timestamp() * 1000)

    resp = session.get_kline(
        category=category,
        symbol=symbol,
        interval=interval,
        start=start_ts,
        end=end_ts,
        limit=1000
    )

    rows = resp["result"]["list"]
    df = pd.DataFrame(
        rows,
        columns=["timestamp", "open", "high", "low", "close", "volume", "turnover"]
    )
    df["datetime"] = pd.to_datetime(df["timestamp"].astype("int64"), unit="ms", utc=True)
    return df.sort_values("datetime").reset_index(drop=True)

Next comes the trade simulator. The entry starts from the first candle at or after entry_dt, followed by a sequential check of TP, SL, and timeout.

The structure is intentionally minimal: one entry point, fixed risk size, and a linear scan through candles until TP, SL, or scenario completion. That makes the result easier to interpret and keeps the logic visible rather than buried under extra layers.

Code Short entry point
mask = df["datetime"] >= entry_dt
if not mask.any():
    return None

entry_idx = mask.idxmax()
entry_price = float(df.loc[entry_idx, "close"])
Code Basic short-trade simulation
def simulate_short_trade(df, entry_dt, entry_amount=100.0,
                         tp_pct=5.0, sl_pct=4.0, duration_min=600):
    mask = df["datetime"] >= entry_dt
    if not mask.any():
        return None

    entry_idx = mask.idxmax()
    entry_price = float(df.loc[entry_idx, "close"])
    pos_qty = entry_amount / entry_price

    tp_price = entry_price * (1 - tp_pct / 100.0)
    sl_price = entry_price * (1 + sl_pct / 100.0)
    end_idx = min(entry_idx + duration_min, len(df) - 1)

    for i in range(entry_idx + 1, end_idx + 1):
        high_i = float(df.loc[i, "high"])
        low_i = float(df.loc[i, "low"])

        if low_i <= tp_price:
            return {"PnL": (entry_price - tp_price) * pos_qty,
                    "closure_type": "TP"}
        if high_i >= sl_price:
            return {"PnL": (entry_price - sl_price) * pos_qty,
                    "closure_type": "STOP"}

    last_close = float(df.loc[end_idx, "close"])
    return {"PnL": (entry_price - last_close) * pos_qty,
            "closure_type": "TIMEOUT"}

On top of that baseline, I added an extended version with multiple TP levels, partial exits, and averaging. Wherever a product needs more flexible position management, these are usually the first blocks that change.

The next layer was parameter selection. Instead of debating whether a 5% TP “sounds more solid” than 4%, I ran a grid search across multiple first and second TP values, several stop-loss levels, and several position-distribution structures, including full exits and split scenarios.

This is the standard workflow for any backtest module: define the parameter grid, run it on the same sample, and only then decide what deserves to move into production.

Code Grid search for TP/SL and TP shares
for tp1, tp2, stop, tp_sh in product(tp1_grid, tp2_grid,
                                     stop_grid, tp_shares_grid):
    tplevels = [tp1, tp2, tp3]

    trades_df_g, total_pnl_g, mean_pnl_g, win_rate_g = run_backtest(
        allpairs=allpairs,
        data_cache=data_cache,
        entry_amount=100.0,
        tplevels=tplevels,
        tpshares=tp_sh,
        stoplosspct=stop,
        avglevels=2,
        avgpct=5.0,
        avgamount=50.0,
        duration_min=600,
    )
If a product needs an “experiment mode,” this is the natural place to plug in alternative parameter grids and compare them across new batches of listings.

On the sample of 10 listings, the best configuration turned out to be a simple one: TP levels = [5, 6, 12], TP shares = [1.0, 0.0, 0.0], stop loss = 4%. In other words, a full exit at the first TP outperformed every split structure in the test.

The final figures for this setup were: total result of 14.95 USD with fixed 100 USD risk per trade, mean result of 1.50 USD per trade, 60% win rate, and a closure structure of 6 TP, 4 SL, and 0 timeouts. For a small sample, these are modest numbers, but they are sufficient to separate a viable hypothesis from a purely emotional one.

Code Summary of the best configuration
print("FINAL SUMMARY")
print("Best TP levels:", best_tplevels)
print("Best TP shares:", best_tpshares)
print("Best stop loss:", best_stop)
print("Trades:", len(best_trades_df))
print("Total PnL:", round(best_total_pnl, 2))
print("Mean PnL:", round(best_mean_pnl, 2))
print("Win rate:", round(best_win_rate * 100, 2), "%")

Entry time was analyzed separately. The weakest window in this sample was 04:00 UTC, while the most consistently positive one was 06:00 UTC. For a product, that gives a simple but useful signal: time is a valid candidate for an additional risk filter rather than a decorative parameter.

Code Entry-hour statistics
hour_stats = (
    analysis_time
    .groupby("entry_hour")
    .agg(
        trades=("PnL", "count"),
        total_pnl=("PnL", "sum"),
        mean_pnl=("PnL", "mean"),
        winrate=("PnL", lambda x: (x > 0).mean())
    )
    .sort_values("entry_hour")
)

What this gives a product

From a solution-architecture perspective, the result can be described simply: the baseline short logic works with a 5% TP and 4% SL, a full exit at the first TP, and a time filter that favors 06:00 UTC while treating 04:00 UTC more cautiously. This is a transparent rule set that can be moved into a product, monitored on a broader sample, and adapted to a different instrument universe when needed.

For teams building market-facing strategies or services, the value is not only in the specific setup but in the method itself: a clear hypothesis, an explicit connection to exchange data, a backtest with a controlled parameter grid, and readable output metrics. Instruments, horizons, and universes may change, but the validation framework remains stable.

  • Foundation: a short-trade simulator on Bybit historical candles with explicit TP/SL control.
  • Conclusion: a simple full exit at TP1 behaved better than split exits on the tested listing sample.
A formal hypothesis test does not guarantee a perfect strategy. It guarantees something else: you stop paying the market for decisions that could have been filtered out at the notebook stage.