호가창 백테스트 체결이 달라졌다면? NautilusTrader batch_deltas와 F_LAST 점검
NautilusTrader의 OrderBookDelta로 L2/L3 호가창을 백테스트하다 업그레이드 후 fills 또는 포지션 수가 이전과 달라졌다면 전략 파라미터부터 수정하지 마세요. 서로 다른 두 문제를 구분해야 합니다. batch_deltas는 매칭 엔진이 중간 호가 상태를 볼 수 있는 시점을 바꿉니다. 더 놓치기 쉬운 문제는 이벤트 앞부분이 조회 범위에서 잘려 나가도 마지막 행에 F_LAST가 남아 있다면 오류가 발생하지 않을 수 있다는 점입니다.
F_LAST는 이벤트 그룹이 끝났음을 표시할 뿐, 처음부터 모두 읽었다는 증거가 아닙니다. 조회 시간대나 filter_expr가 첫 ADD를 제거하고 F_LAST가 붙은 마지막 DELETE만 남겨도, 배처는 그 조각을 정상적으로 닫을 수 있습니다. v2.0.0rc7 버전에 고정한 공식 백테스트 문서도 이벤트 시작 부분의 누락이 감지되지 않을 수 있다고 경고합니다.
여기서는 NautilusTrader v2.0.0rc7 소스와 상위 테스트를 확인하고 Python 표준 라이브러리로 작성한 Toy Example로 이벤트 경계를 재현합니다. 자신의 Catalog에서는 아래 설정을 이용해 두 가지 재생 방식을 비교하세요. 부록 코드는 F_LAST 그룹화 원리를 설명하는 합성 예제이며 NautilusTrader 엔진을 직접 실행한 기록은 아닙니다.
전략을 바꾸기 전에 확인할 네 가지
같은 데이터 Catalog를 사용했는데 백테스트 결과가 달라졌다면 다음 순서로 추적하는 편이 좋습니다.
- 기준을 고정합니다. 업그레이드 전후 버전, Catalog 내용, 조회 기간, 종목 정밀도, 전략, 주문 및 매칭 설정을 기록합니다. 원인을 찾는 도중에 원본 시세 데이터를 다시 생성하지 마세요.
- 이벤트 완전성을 검사합니다.
instrument_id별로F_LAST를 확인하고 특히 조회 기간의 첫 이벤트가 중간부터 시작하지 않았는지 살펴봅니다. 끝 표시가 있다고 앞부분까지 완전한 것은 아닙니다. - 설정 하나만 바꿉니다. 데이터와 전략, 매칭 가정을 고정한 상태에서
batch_deltas=True와False를 비교합니다. 첫 번째 체결 또는 콜백 차이가 어디서 생겼는지 추적하세요. - 그다음 기준을 정합니다. 어떤 설정의 수익률이 높은지가 아니라 데이터 제공자가 정의한 이벤트 단위를 기준으로 재생 방식을 결정합니다.
공식 BacktestDataConfig 예제를 참고해 기존 백테스트 코드에서 먼저 batch_deltas=True를 명시합니다. 비교 실험에서는 그 값만 False로 바꾸고 Catalog 경로와 instrument_id는 동일하게 유지하세요.
data = BacktestDataConfig(
data_type=NautilusDataType.OrderBookDelta,
catalog_path="/data/catalog",
instrument_id=instrument_id,
batch_deltas=True, # rc7 默认:按完整事件回放
)
# 对照组保持其余配置不变,仅将 batch_deltas 改为 False
rc7의 Rust 설정 코드에는 기본값 true가 정의되어 있습니다. Python 설정의 상위 테스트도 기본값, 비활성화, 활성화 경우를 다룹니다. False는 진단을 위한 비교 조건이지 손실된 호가 데이터를 복원하는 기능이 아닙니다.
F_LAST로 끝부분 누락은 찾을 수 있어도 시작 부분은 놓칠 수 있다
하나의 시세 이벤트에서 매도 호가 999.95를 ADD한 다음 DELETE한다고 합시다. 마지막 DELETE에만 F_LAST가 붙습니다.
| 조회 구간 | 남아 있는 Delta | 이벤트 그룹의 결과 |
|---|---|---|
| 전체 | ADD 999.95 → DELETE 999.95 (F_LAST) | 두 행이 완전한 이벤트로 묶임 |
| 끝부분 잘림 | F_LAST가 없는 ADD 999.95만 남음 | 닫히지 않은 그룹이 남아 입력 종료 시 오류 가능 |
| 시작 부분 잘림 | F_LAST가 붙은 DELETE 999.95만 남음 | 불완전한 한 행이어도 그룹이 닫힐 수 있음 |
BookDeltasBatcher 구현은 instrument_id별로 Delta를 모았다가 F_LAST를 만나면 OrderBookDeltas로 내보냅니다. 입력이 끝났을 때 확인하는 것은 닫히지 않은 그룹이 있는지 여부입니다. 쿼리에서 이미 제거된 첫 ADD는 배처가 알 수 없습니다.
이는 F_LAST 그룹 경계 검증에 대한 이야기입니다. 존재하지 않는 호가를 DELETE하거나 스냅샷이 누락되면 이후 단계에서 다른 오류가 날 수 있습니다. 원본 Catalog 또는 시세 공급자의 이벤트 정의를 다시 확인하고, 필요하다면 읽기 시작 시점을 앞당겨 완전한 이벤트나 신뢰할 수 있는 스냅샷을 확보하세요. F_LAST 개수만 세는 것으로 무결성이 증명되지는 않습니다.
동일한 호가 Delta에서도 체결이 달라지는 이유
데이터가 완전하고 동일해도 매칭 시점에 따라 결과는 달라질 수 있습니다. 1000.00에 매수 지정가 주문을 걸어두었고 원래 최우선 매도 호가가 1000.10이라고 가정해 봅시다. 다음 이벤트에서 999.95 매도 호가를 ADD했다가 곧 DELETE하며 마지막 DELETE에 F_LAST를 붙입니다.
각 Delta를 적용할 때마다 매칭하면 매수 주문이 잠깐 존재한 999.95를 보게 됩니다. 전체 이벤트를 적용한 후 매칭하면 그 호가는 이미 사라졌습니다. 이는 단순한 배치 성능 문제가 아니라 중간 호가 상태가 매칭 로직에 보이느냐의 차이입니다.
| 재생 방식 | 매칭 시점의 호가창 | Toy Example 모의 체결 |
|---|---|---|
| Delta 단위 | 일시적으로 999.95가 존재 | 1 |
F_LAST 이벤트 전체 | 999.95가 이미 제거됨 | 0 |
상위 Rust 통합 테스트는 비슷한 임시 매도 호가 상황을 구성하고 #[case::batched(true, 0)]와 #[case::individual(false, 1)]를 total_positions의 예상값으로 지정합니다. 이 값들은 상위 테스트 소스에 정의된 기대값입니다. 표의 1/0은 별도로 실행한 Toy Model의 모의 체결 횟수입니다. 두 지표를 같은 실행 결과로 취급해서는 안 됩니다.
공식 재생 문서에서는 마지막 Delta의 ts_init에 맞춰 묶음을 재생하고 on_book_deltas 콜백과 스트리밍 chunk_size도 묶음 단위로 바뀐다고 설명합니다. 최종 PnL만 비교하지 말고 가장 먼저 다른 결과가 나온 이벤트와 콜백부터 확인해야 합니다.
확인한 v2.0.0rc6 설정 코드에는 batch_deltas 필드가 없습니다. 설정 API가 달라졌다는 증거이지만 과거의 모든 실행 경로가 False와 정확히 같았다는 뜻은 아닙니다.
재현 가능한 전체 코드와 부정 테스트
Python 3.13.5 표준 라이브러리로 단순한 호가창과 체결 판정 모델을 만들었습니다. 개별 재생에서는 individual=1, 이벤트 단위 재생에서는 batched=0이 나옵니다. 종료 F_LAST 누락, 종목별 Delta 교차 입력, 타임스탬프가 같은 독립 이벤트도 검사했습니다. 최초 스크립트는 5/5, 확장 unittest는 10/10 통과했습니다.
가장 중요한 부정 테스트는 이벤트의 시작이 사라졌는데도 나머지 조각은 닫힐 수 있다는 상황입니다. 테스트 통과는 이런 검증의 사각지대를 재현했다는 뜻이지 손상된 데이터가 복구됐다는 뜻이 아닙니다. 두 테스트 집합은 일부 항목이 겹치므로 열다섯 개의 PASS를 열다섯 개의 독립적인 시장 관측으로 해석할 수도 없습니다.
실행 가능한 전체 코드와 원래 실행 출력을 아래에 그대로 보존했습니다. 첫 번째 파일을 book_batching_fixture.py로 저장하고 python3 book_batching_fixture.py를 실행하세요. 두 번째 edge_case_verification.py는 같은 디렉터리에 두고 python3 -m unittest -v edge_case_verification를 실행합니다. 표준 라이브러리와 가상 가격만 사용하며 실제 거래소 데이터, 수수료, 주문 대기열은 포함하지 않습니다.
최초 5/5 합성 모델: 전체 코드와 실제 출력
"""Self-authored toy model of F_LAST event boundaries, NOT NautilusTrader runtime."""
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Delta:
instrument: str
action: str
price: Decimal
last: bool
ts_init: int
def d(instrument, action, price, last, timestamp):
return Delta(instrument, action, Decimal(price), last, timestamp)
def group_by_last(rows):
pending = {}
for row in rows:
event = pending.setdefault(row.instrument, [])
event.append(row)
if row.last:
yield tuple(event)
del pending[row.instrument]
if pending:
detail = ", ".join(sorted(pending))
raise ValueError(f"missing F_LAST for {detail}")
def toy_fills(rows, batch_deltas):
"""One pending BUY at 1000; match after each replay item, not each event row."""
asks = set()
buy_pending = True
fills = 0
replay_items = group_by_last(rows) if batch_deltas else ((r,) for r in rows)
for item in replay_items:
for row in item:
if row.action == "ADD":
asks.add(row.price)
elif row.action == "DELETE":
asks.discard(row.price)
if buy_pending and asks and min(asks) <= Decimal("1000.00"):
buy_pending = False
fills += 1
return fills
def run():
# The same synthetic event shape as the concept behind the pinned upstream test.
rows = [
d("SIM", "ADD", "1000.10", True, 1),
d("SIM", "ADD", "999.95", False, 2),
d("SIM", "DELETE", "999.95", True, 2),
]
groups = list(group_by_last(rows))
assert [len(g) for g in groups] == [1, 2]
print("PASS 1/5 event boundaries: 3 deltas -> groups [1, 2]")
individual, batched = toy_fills(rows, False), toy_fills(rows, True)
assert (individual, batched) == (1, 0)
print(f"PASS 2/5 toy fills: individual={individual}, batched={batched}")
try:
list(group_by_last([d("SIM", "ADD", "1.00", False, 3)]))
except ValueError as exc:
assert "missing F_LAST for SIM" in str(exc)
else:
raise AssertionError("unterminated event did not fail")
print("PASS 3/5 unterminated event rejected")
interleaved = [
d("A", "ADD", "1", False, 4),
d("B", "ADD", "2", True, 4),
d("A", "DELETE", "1", True, 5),
]
received = list(group_by_last(interleaved))
assert [[r.instrument for r in group] for group in received] == [["B"], ["A", "A"]]
print("PASS 4/5 interleaved instruments remain isolated")
same_time = [d("SIM", "ADD", "1", True, 6), d("SIM", "ADD", "2", True, 6)]
assert [len(g) for g in group_by_last(same_time)] == [1, 1]
print("PASS 5/5 equal-timestamp completed events stay separate")
print("SUMMARY: 5/5 PASS, self-authored stdlib model only; no upstream engine run")
if __name__ == "__main__":
run()
PASS 1/5 event boundaries: 3 deltas -> groups [1, 2]
PASS 2/5 toy fills: individual=1, batched=0
PASS 3/5 unterminated event rejected
PASS 4/5 interleaved instruments remain isolated
PASS 5/5 equal-timestamp completed events stay separate
SUMMARY: 5/5 PASS, self-authored stdlib model only; no upstream engine run
확장 10/10 경계 테스트: 전체 코드와 실제 출력
"""Independent negative-control suite for the self-authored F_LAST toy model.
Not NautilusTrader tests and not an exchange/market backtest.
"""
import unittest
from book_batching_fixture import d, group_by_last, toy_fills
class OrderBookEventBoundaryControls(unittest.TestCase):
def test_01_trailing_incomplete_event_is_rejected(self):
rows = [d("SIM", "ADD", "999.95", False, 10)]
with self.assertRaisesRegex(ValueError, "missing F_LAST for SIM"):
list(group_by_last(rows))
def test_02_head_truncated_event_is_not_detected_by_f_last(self):
full_event = [
d("SIM", "ADD", "999.95", False, 10),
d("SIM", "DELETE", "999.95", True, 10),
]
complete = list(group_by_last(full_event))
filtered = list(group_by_last(full_event[1:]))
self.assertEqual([len(g) for g in complete], [2])
# This is a NEGATIVE result: F_LAST alone cannot validate the missing head.
self.assertEqual([len(g) for g in filtered], [1])
self.assertEqual(filtered[0][0].action, "DELETE")
def test_03_unbatched_mode_accepts_unclosed_intermediate_row(self):
rows = [d("SIM", "ADD", "999.95", False, 10)]
self.assertEqual(toy_fills(rows, batch_deltas=False), 1)
with self.assertRaises(ValueError):
toy_fills(rows, batch_deltas=True)
def test_04_persistent_ask_matches_in_both_modes(self):
rows = [d("SIM", "ADD", "999.95", True, 10)]
self.assertEqual(toy_fills(rows, False), 1)
self.assertEqual(toy_fills(rows, True), 1)
def test_05_transient_ask_exposes_event_granularity(self):
rows = [
d("SIM", "ADD", "1000.10", True, 10),
d("SIM", "ADD", "999.95", False, 20),
d("SIM", "DELETE", "999.95", True, 20),
]
self.assertEqual((toy_fills(rows, False), toy_fills(rows, True)), (1, 0))
def test_06_equal_timestamps_do_not_merge_closed_events(self):
rows = [d("SIM", "ADD", "1", True, 10), d("SIM", "ADD", "2", True, 10)]
self.assertEqual([len(g) for g in group_by_last(rows)], [1, 1])
def test_07_interleaved_symbols_keep_independent_buffers(self):
rows = [
d("A", "ADD", "1", False, 10),
d("B", "ADD", "2", True, 10),
d("A", "DELETE", "1", True, 11),
]
self.assertEqual([[r.instrument for r in g] for g in group_by_last(rows)], [["B"], ["A", "A"]])
def test_08_partial_symbol_does_not_disappear_at_end(self):
rows = [d("A", "ADD", "1", False, 10), d("B", "ADD", "2", True, 11)]
iterator = iter(group_by_last(rows))
self.assertEqual([x.instrument for x in next(iterator)], ["B"])
with self.assertRaisesRegex(ValueError, "missing F_LAST for A"):
next(iterator)
def test_09_empty_input_is_accepted(self):
self.assertEqual(list(group_by_last([])), [])
def test_10_closing_delta_has_last_timestamp_in_toy(self):
rows = [d("SIM", "ADD", "1", False, 10), d("SIM", "DELETE", "1", True, 12)]
grouped = list(group_by_last(rows))
self.assertEqual(grouped[0][-1].ts_init, 12)
self.assertEqual(len(grouped[0]), 2)
if __name__ == "__main__":
unittest.main(verbosity=2)
test_01_trailing_incomplete_event_is_rejected (edge_case_verification.OrderBookEventBoundaryControls.test_01_trailing_incomplete_event_is_rejected) ... ok
test_02_head_truncated_event_is_not_detected_by_f_last (edge_case_verification.OrderBookEventBoundaryControls.test_02_head_truncated_event_is_not_detected_by_f_last) ... ok
test_03_unbatched_mode_accepts_unclosed_intermediate_row (edge_case_verification.OrderBookEventBoundaryControls.test_03_unbatched_mode_accepts_unclosed_intermediate_row) ... ok
test_04_persistent_ask_matches_in_both_modes (edge_case_verification.OrderBookEventBoundaryControls.test_04_persistent_ask_matches_in_both_modes) ... ok
test_05_transient_ask_exposes_event_granularity (edge_case_verification.OrderBookEventBoundaryControls.test_05_transient_ask_exposes_event_granularity) ... ok
test_06_equal_timestamps_do_not_merge_closed_events (edge_case_verification.OrderBookEventBoundaryControls.test_06_equal_timestamps_do_not_merge_closed_events) ... ok
test_07_interleaved_symbols_keep_independent_buffers (edge_case_verification.OrderBookEventBoundaryControls.test_07_interleaved_symbols_keep_independent_buffers) ... ok
test_08_partial_symbol_does_not_disappear_at_end (edge_case_verification.OrderBookEventBoundaryControls.test_08_partial_symbol_does_not_disappear_at_end) ... ok
test_09_empty_input_is_accepted (edge_case_verification.OrderBookEventBoundaryControls.test_09_empty_input_is_accepted) ... ok
test_10_closing_delta_has_last_timestamp_in_toy (edge_case_verification.OrderBookEventBoundaryControls.test_10_closing_delta_has_last_timestamp_in_toy) ... ok
----------------------------------------------------------------------
Ran 10 tests in 0.001s
OK
새로운 결과로 과거 백테스트 기준을 바꿔야 할까?
batch_deltas=True에서 체결 수가 줄었다고 더 현실적인 수익이 되었다고 단정할 수는 없습니다. 이전 수익 곡선을 지키기 위해 False로 되돌리는 것도 적절하지 않습니다. 먼저 데이터 제공자의 이벤트 정의를 확인하고 그다음 체결 차이를 해석해야 합니다. 여러 Delta가 하나의 논리적 이벤트라면 이를 한꺼번에 적용하는 것이 그 데이터의 의미에 맞습니다. 시작 데이터가 잘려 나갔다면 어떤 설정값도 잃어버린 상태를 복원하지 못합니다.
이전 기준을 남겨두고 차이가 처음 발생한 OrderBookDelta의 F_LAST, ts_init, 원래 이벤트 분할을 확인한 뒤에 수수료와 주문 대기열, 체결 비용, 전략 수익을 분석하세요. vectorbt CVaR의 표본 분모나 반복 종목 선정의 승률 분모와 같은 교훈입니다. 정량 결과를 해석하려면 무엇을 하나의 완전한 관측으로 셀지 먼저 정해야 합니다.
버전 및 검증 기록: NautilusTrader v2.0.0rc7 소스는 커밋 55c0e82053142bce3a5cba4de4221d3929c2d3bc에 고정했습니다. Python 3.13.5 합성 모델과 5+10개 경계 테스트는 2026년 10월 18일에 기록했습니다. Toy Example은 교육용이며 NautilusTrader 엔진 직접 실행, 실제 과거 시세 또는 실거래 백테스트가 아닙니다. 이 글은 투자 조언이 아닌 재생 데이터 엔지니어링 설명입니다.