Skip to content

Roundtrips

Round-trip trade computation from fill records.

Aggregates fill events into complete round-trip trades with P&L, duration, high watermark, and maximum drawdown metrics.

compute_watermarks_and_drawdown(conn, run_id, symbol, direction, avg_entry, quantity, entry_ts, exit_ts)

Compute high watermark, low watermark, maximum drawdown, and bar count for a round-trip trade.

Iterates through bars during the trade period to track: - High watermark: the best unrealized P&L reached - Low watermark: the worst unrealized P&L reached - Max drawdown: the largest decline from any peak to any subsequent trough - Duration bars: number of bars from entry to exit (inclusive)

Note: Within each bar, we assume the high occurred before the low (worst-case assumption for drawdown calculation), since intra-bar sequence is unknown.

Parameters:

Name Type Description Default
conn Connection

SQLite database connection.

required
run_id str

Unique identifier of the backtest run.

required
symbol str

Instrument symbol for the trade.

required
direction str

Trade direction, either "LONG" or "SHORT".

required
avg_entry float

Average entry price.

required
quantity float

Total position quantity.

required
entry_ts int

Entry timestamp in nanoseconds.

required
exit_ts int

Exit timestamp in nanoseconds.

required

Returns:

Type Description
tuple[float, float, float, int]

Tuple of (high_watermark, low_watermark, max_drawdown, duration_bars) in currency units.

Source code in src/onesecondtrader/dashboard/roundtrips.py
def compute_watermarks_and_drawdown(
    conn: sqlite3.Connection,
    run_id: str,
    symbol: str,
    direction: str,
    avg_entry: float,
    quantity: float,
    entry_ts: int,
    exit_ts: int,
) -> tuple[float, float, float, int]:
    """
    Compute high watermark, low watermark, maximum drawdown, and bar count for a round-trip trade.

    Iterates through bars during the trade period to track:
    - High watermark: the best unrealized P&L reached
    - Low watermark: the worst unrealized P&L reached
    - Max drawdown: the largest decline from any peak to any subsequent trough
    - Duration bars: number of bars from entry to exit (inclusive)

    Note: Within each bar, we assume the high occurred before the low (worst-case
    assumption for drawdown calculation), since intra-bar sequence is unknown.

    Parameters:
        conn:
            SQLite database connection.
        run_id:
            Unique identifier of the backtest run.
        symbol:
            Instrument symbol for the trade.
        direction:
            Trade direction, either "LONG" or "SHORT".
        avg_entry:
            Average entry price.
        quantity:
            Total position quantity.
        entry_ts:
            Entry timestamp in nanoseconds.
        exit_ts:
            Exit timestamp in nanoseconds.

    Returns:
        Tuple of (high_watermark, low_watermark, max_drawdown, duration_bars) in currency units.
    """
    cursor = conn.cursor()
    cursor.execute(
        """
        SELECT high, low FROM bars
        WHERE run_id = ? AND symbol = ? AND ts_event_ns >= ? AND ts_event_ns <= ?
        ORDER BY ts_event_ns
        """,
        (run_id, symbol, entry_ts, exit_ts),
    )
    bars = cursor.fetchall()

    if not bars:
        return 0.0, 0.0, 0.0, 0

    high_watermark = 0.0
    low_watermark = 0.0
    max_drawdown = 0.0
    running_peak = 0.0
    duration_bars = len(bars)

    for bar_high, bar_low in bars:
        if direction == "LONG":
            best_pnl = (bar_high - avg_entry) * quantity
            worst_pnl = (bar_low - avg_entry) * quantity
        else:
            best_pnl = (avg_entry - bar_low) * quantity
            worst_pnl = (avg_entry - bar_high) * quantity

        if best_pnl > high_watermark:
            high_watermark = best_pnl

        if worst_pnl < low_watermark:
            low_watermark = worst_pnl

        if best_pnl > running_peak:
            running_peak = best_pnl

        drawdown = running_peak - worst_pnl
        if drawdown > max_drawdown:
            max_drawdown = drawdown

    return high_watermark, low_watermark, max_drawdown, duration_bars

get_roundtrips(run_id)

Compute round-trip trades from fill records for a run.

Aggregates fills by symbol into complete round-trips, calculating entry/exit prices, P&L, duration, high watermark, and maximum drawdown for each.

Parameters:

Name Type Description Default
run_id str

Unique identifier of the backtest run.

required

Returns:

Type Description
list[dict]

List of round-trip dictionaries with symbol, direction, duration, max_position,

list[dict]

high_watermark, max_drawdown, pnl_before_commission, pnl_after_commission,

list[dict]

entry_ts, and exit_ts.

Source code in src/onesecondtrader/dashboard/roundtrips.py
def get_roundtrips(run_id: str) -> list[dict]:
    """
    Compute round-trip trades from fill records for a run.

    Aggregates fills by symbol into complete round-trips, calculating entry/exit
    prices, P&L, duration, high watermark, and maximum drawdown for each.

    Parameters:
        run_id:
            Unique identifier of the backtest run.

    Returns:
        List of round-trip dictionaries with symbol, direction, duration, max_position,
        high_watermark, max_drawdown, pnl_before_commission, pnl_after_commission,
        entry_ts, and exit_ts.
    """
    db_path = get_runs_db_path()
    if not os.path.exists(db_path):
        return []
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    cursor.execute(
        """
        SELECT symbol, side, quantity_filled, fill_price, commission, ts_broker_ns
        FROM fills
        WHERE run_id = ?
        ORDER BY symbol, ts_broker_ns
        """,
        (run_id,),
    )
    rows = cursor.fetchall()

    fills_by_symbol: dict[str, list] = {}
    for row in rows:
        symbol = row[0]
        fills_by_symbol.setdefault(symbol, []).append(
            {
                "side": row[1],
                "quantity": row[2],
                "price": row[3],
                "commission": row[4],
                "ts_ns": row[5],
            }
        )

    roundtrips = []
    for symbol, fills in fills_by_symbol.items():
        position = 0.0
        entry_fills: list[dict] = []
        entry_value = 0.0
        entry_commission = 0.0
        max_pos = 0.0
        start_ts: int = 0

        for fill in fills:
            signed_qty = (
                fill["quantity"] if fill["side"] == "BUY" else -fill["quantity"]
            )
            if position == 0.0:
                start_ts = fill["ts_ns"]
                entry_fills = []
                entry_value = 0.0
                entry_commission = 0.0
                max_pos = 0.0

            position += signed_qty
            max_pos = max(max_pos, abs(position))

            if (signed_qty > 0 and position > 0) or (signed_qty < 0 and position < 0):
                entry_value += fill["price"] * fill["quantity"]
                entry_commission += fill["commission"]
                entry_fills.append(fill)
            else:
                exit_commission = fill["commission"]

                if abs(position) < 1e-9:
                    direction = (
                        "LONG"
                        if entry_fills and entry_fills[0]["side"] == "BUY"
                        else "SHORT"
                    )
                    total_entry_qty = sum(f["quantity"] for f in entry_fills)
                    avg_entry = (
                        entry_value / total_entry_qty if total_entry_qty > 0 else 0
                    )
                    avg_exit = fill["price"]
                    total_commission = entry_commission + exit_commission

                    if direction == "LONG":
                        pnl_before_commission = (avg_exit - avg_entry) * total_entry_qty
                    else:
                        pnl_before_commission = (avg_entry - avg_exit) * total_entry_qty

                    pnl_after_commission = pnl_before_commission - total_commission

                    hwm, lwm, mdd, duration_bars = compute_watermarks_and_drawdown(
                        conn,
                        run_id,
                        symbol,
                        direction,
                        avg_entry,
                        total_entry_qty,
                        start_ts,
                        fill["ts_ns"],
                    )

                    roundtrips.append(
                        {
                            "symbol": symbol,
                            "direction": direction,
                            "duration_bars": duration_bars,
                            "max_position": round(max_pos, 4),
                            "high_watermark": round(hwm, 2),
                            "low_watermark": round(lwm, 2),
                            "max_drawdown": round(mdd, 2),
                            "pnl_before_commission": round(pnl_before_commission, 2),
                            "pnl_after_commission": round(pnl_after_commission, 2),
                            "entry_ts": start_ts,
                            "exit_ts": fill["ts_ns"],
                        }
                    )
                    position = 0.0

    conn.close()
    return roundtrips