π νκ΅μ΄λ‘ 보기
π Toss Auto-Trading Dev Journal (Phase 4 / 13 entries so far)
- #0 Β· Phase 0 β Foundation
- #0-1 Β· Phase 0-1 β Currency bug fix
- #3 Β· Phase 1 β Config split & retry logic
- #4 Β· Phase 1-2 β Retry logic follow-up
- #5 Β· Phase 1-3 β Introducing pytest
- #6 Β· Phase 1-4 β Log rotation
- #7 Β· Phase 2-1 β Strategy class abstraction
- #8 Β· Phase 2-2 β Multi-symbol monitoring
- #9 Β· Phase 3-1 β Data persistence design
- #10 Β· Phase 3-2 β DB module implementation
- #11 Β· Phase 3-3 β DB integration
- #12 Β· Phase 2-3 β Strategy plugin interface
- #13 Β· Phase 4 β Backtest engine design (this post)
Previous: Phase 2-3 β Strategy plugin interface
π‘ Phase 4 at a glance
- Goal: bolt a backtest engine onto the bot without touching strategy code, driven purely by a price list, computing win rate, profit factor, and max drawdown.
- Key decision: separate data source from backtest logic, and keep exit rules (take-profit/stop-loss) inside
backtest.pyrather thanstrategy.py. - Verification: 13 new tests in
test_backtest.py, full suite at 71/71 passing.
Phases 0 through 3 covered the foundation, stability, strategy modularity, and data persistence. Next on the roadmap: backtesting. First question that stopped me cold β where do I even get historical prices?
Separating “where the data comes from” from “how the backtest works”
I wasn’t sure whether the Toss Securities Open API even exposes a historical-price endpoint β that check got pushed to next session. My own price_snapshots table also hasn’t run long enough to be useful yet.
So I made a call: backtest.py doesn’t care where prices come from. Feed it a plain list[float] β self-collected data, public external data, or masked synthetic data using placeholder tickers like 005930 and 000660 β and it behaves identically.
def run_backtest(strategy, prices: list[float], take_profit=0.05, stop_loss=0.03):
trades = []
i = 0
while i < len(prices):
if strategy.should_buy(prices[:i+1]):
entry_price = prices[i]
exit_i, reason = _find_exit(prices, i, entry_price, take_profit, stop_loss)
trades.append({
"entry": entry_price,
"exit": prices[exit_i],
"reason": reason, # "take_profit" | "stop_loss" | "period_end"
})
i = exit_i + 1
else:
i += 1
return trades
strategy.py stays untouched β exit rules live elsewhere
The existing should_buy() only ever answered “when to buy.” In live trading that was fine, since the bot stops watching a symbol right after a buy. But win rate and profit factor need an exit point.
Instead of touching the strategy class, I added take-profit (+5%) and stop-loss (-3%) thresholds to config.py, and let backtest.py own the exit decision entirely. Strategy owns entries; the backtest engine owns exits.
compute_metrics() β win rate, profit factor, drawdown
compute_metrics(trades) takes the trade list and computes win rate, profit factor (average win / average loss), and max drawdown off the cumulative equity curve. When there are zero losing trades, profit factor comes back as None rather than an arbitrary infinity or zero β so “no losses yet” never gets confused with “profit factor is zero” in the report.
A backtest engine that only consumes a price list β while keeping exit rules separate from the strategy class β lets you add backtesting without touching existing trading logic at all.
One test failed out of 13 β and it wasn’t a code bug
First pytest run against the new test_backtest.py gave a FAILED on test_stop_loss_trigger. I assumed a logic bug and spent a while inside run_backtest, but the real problem was the test data itself β prices stayed below the entry threshold even after the stop-loss, so the engine correctly re-entered a new trade. Fixing the price sequence in the test fixed everything.
Full suite: 58 existing + 13 new = 71/71 passing, with no changes to any safety thresholds. Running the engine against 30 days of synthetic price data produced 4 trades (3 take-profit, 1 stop-loss), a 75% win rate, a 1.90 profit factor, and 3.12% max drawdown.
A known limitation I left in place
MovingAverageStrategy, which keeps internal price history, has a quirk: because the backtest loop skips ahead after a sell, the skipped segment never feeds into its moving-average calculation. The stateless ThresholdBuyStrategy has no such issue. I documented this directly in the code comments so I don’t re-discover it later.
What’s next
Two of Phase 4’s three pieces are done: simulation and reporting. What’s left is checking whether Toss Securities’ Open API exposes daily/minute historical price endpoints β if it does, that becomes the primary data source, and the next step is running backtest.py across strategies to actually compare performance.
Pingback: κ°κ²© 리μ€νΈ νλλ‘ μΉλ₯ κΉμ§: νμ΄λΈλ¦¬λ λ°±ν μ€νΈ μμ§ λ§λ€κΈ° | ν μ€ μλλ§€λ§€ κ°λ°κΈ° #13 (Phase 4) - TS ννΈ: μ£Όμ μλλ§€λ§€ μ°κ΅¬μ