Новий контракт з’являється на біржі, перші хвилини все летить у різні боки, далі частина учасників згадує про ризик‑менеджмент. Це коротке «післялістингове» вікно і стало об’єктом цього кейсу.

Завдання виглядало стандартно для будь‑якого продукту з ринковою логікою: формалізувати гіпотезу, зібрати дані, побудувати симулятор, прогнати параметри і побачити, чи є стабільна перевага. Конкретно тут — short‑угоди на confirmed listing events Bybit з фіксованим ризиком та чіткою логікою TP/SL.

Якщо стратегія не проходить простий backtest, вона не має шансу вижити в середовищі, де рішення приймаються на швидкості продукту, а не презентації.

Для тесту я використала десять confirmed listing events USDT‑контрактів. Для кожного запуску завантажувалось хвилинне вікно даних: від 18 годин до моменту лістингу до 10 годин після. Pre‑listing частина на цьому етапі збережена як контекст для майбутнього аналізу патернів, post‑listing частина використовується безпосередньо в симуляції угоди. Дані бралися через Bybit V5 Market Kline endpoint, який повертає historical candles для заданого символу, інтервалу та часових меж.

Момент лістингу задається як entry_dt, а рамка даних — як start_dt і end_dt. У результаті в моделі є нормальний контекст перед подією і повний обрізок після неї.

Code Вікно даних навколо лістингу
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")

Функція отримання свічок приводить timestamps до мілісекунд, робить запит до API й повертає впорядкований DataFrame. Ніяких «синтетичних» даних, лише те, що лежить в історії інструмента. [web:133]

Code Отримання historical kline
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)

Далі — симулятор угоди. Вхід починається з першої свічки не раніше entry_dt, далі послідовно перевіряються TP, SL і тайм‑аут.

Схема навмисно мінімалістична: одна точка входу, фіксований розмір ризику, лінійний перебір свічок до спрацьовування TP/SL або завершення сценарію. Такий каркас дозволяє швидко інтерпретувати результат і не ховати логику в шарах складності.

Code Точка входу в short
mask = df["datetime"] >= entry_dt
if not mask.any():
    return None

entry_idx = mask.idxmax()
entry_price = float(df.loc[entry_idx, "close"])
Code Базова симуляція short‑угоди
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"}

На цю основу накладено розширену версію з кількома TP‑рівнями, частковими виходами та усередненням. Там, де продукту потрібна гнучкіша логіка керування позицією, саме ці блоки і змінюються першими.

Наступний шар — підбір параметрів. Замість сперечатися, чи TP 5% «звучить солідніше», ніж 4%, було прогнано grid search: кілька варіантів першого і другого TP, кілька рівнів стоп‑лоса і кілька структур розподілу позиції (full exit і split‑сценарії).

Підхід стандартний для будь‑якого backtest‑модуля: визначити сітку параметрів, прогнати їх на однаковій вибірці й уже потім дивитись, що має сенс переносити в продакшн.

Code Grid search по TP/SL та 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,
    )
Якщо продукту потрібен «режим експерименту», саме тут зручно підключати альтернативні сітки параметрів і порівнювати їх на нових батчах листингів.

На вибірці з 10 лістингів найкращим став простий набір: TP levels = [5, 6, 12], TP shares = [1.0, 0.0, 0.0], stop loss = 4%. Тобто повний вихід на першому TP виявився ефективнішим за будь‑які split‑структури.

Кінцеві цифри для цієї конфігурації: сумарний результат 14.95 USD при фіксованому ризику 100 USD на угоду, середній результат 1.50 USD на трейд, win rate 60%, структура закриттів — 6 TP, 4 SL, 0 timeout. Для маленької вибірки це скромні величини, але цього достатньо, щоб відрізнити життєздатну гіпотезу від суто емоційної.

Code Підсумок найкращої конфігурації
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), "%")

Окремо був прорахований час входу. Найслабше вікно на цій вибірці — 04:00 UTC; найбільш стабільно позитивне — 06:00 UTC. Для продукту це дає простий, але корисний сигнал: час — валідний кандидат на додатковий фільтр ризику, а не лише декоративний параметр.

Code Статистика по годинах входу
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")
)

Що це дає продукту

З точки зору архітектури рішення результат можна описати так: базова short‑логіка працює з TP 5% та SL 4%, повним виходом на першому TP і часовим фільтром, який віддає перевагу 06:00 UTC і змушує ставитись обережніше до 04:00 UTC. Це прозорий набір правил, який легко перенести в продукт, змоніторити на ширшій вибірці та, за потреби, адаптувати під інший універс інструментів.

Для команд, що будують власні стратегії чи сервіси на ринку, важливий не тільки конкретний сетап, а сам підхід: чітке формулювання гіпотези, явний зв’язок із даними біржі, backtest із контрольованою сіткою параметрів і читабельні метрики на виході. У цій конструкції можна змінювати інструменти, горизонти, універс, але механіка перевірки лишиться стабільною.

  • База: short‑симулятор на історичних свічках Bybit із нормальним контролем TP/SL.
  • Висновок: простий full exit на TP1 показав кращу поведінку, ніж split‑виходи, на тестовому наборі лістингів.
Формальна перевірка гіпотези не гарантує ідеальну стратегію. Вона гарантує одне: ви перестаєте платити ринку за ті рішення, які можна було відфільтрувати ще на стадії ноутбука.