test_cli.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  1. import json
  2. from dataclasses import asdict
  3. from pathlib import Path
  4. import pytest
  5. from okx_codex_trader.backtest import run_backtest
  6. from okx_codex_trader.cli import main_factory
  7. from okx_codex_trader.config import Config
  8. from okx_codex_trader.models import Candle, TradeSignal
  9. def sample_config() -> Config:
  10. return Config(api_key="key", api_secret="secret", api_passphrase="passphrase")
  11. def sample_candles(limit: int = 60, symbol: str = "BTC-USDT-SWAP") -> list[Candle]:
  12. candles = []
  13. for index in range(limit):
  14. price = 100.0 + index
  15. candles.append(
  16. Candle(
  17. symbol=symbol,
  18. ts=index,
  19. open=price,
  20. high=price + 1.0,
  21. low=price - 1.0,
  22. close=price + 0.5,
  23. volume=1_000.0 + index,
  24. )
  25. )
  26. return candles
  27. def valid_signal() -> dict[str, object]:
  28. return {
  29. "action": "long",
  30. "confidence": 0.8,
  31. "leverage": 2,
  32. "entry_price": 123.5,
  33. "take_profit_price": 130.0,
  34. "stop_loss_price": 119.0,
  35. "reason": "trend",
  36. }
  37. def fake_analyze_with_codex(candles: list[Candle], symbol: str, bar: str) -> TradeSignal:
  38. assert candles
  39. assert symbol == "BTC-USDT-SWAP"
  40. assert bar == "1H"
  41. return TradeSignal(**valid_signal())
  42. def real_write_text(path: str, text: str) -> None:
  43. Path(path).write_text(text)
  44. class FakeClient:
  45. def __init__(self):
  46. self.get_candles_called_with: tuple[str, str, int] | None = None
  47. self.get_last_price_called_with: str | None = None
  48. def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
  49. self.get_candles_called_with = (symbol, bar, limit)
  50. return sample_candles(limit=limit, symbol=symbol)
  51. def get_last_price(self, symbol: str) -> float:
  52. self.get_last_price_called_with = symbol
  53. return 250.0
  54. def fake_client() -> FakeClient:
  55. return FakeClient()
  56. def build_main_with_stubs(*, state_path: Path | None = None):
  57. client = fake_client()
  58. report_calls: list[dict[str, object]] = []
  59. bbmr_report_calls: list[dict[str, object]] = []
  60. bbsb_report_calls: list[dict[str, object]] = []
  61. donchian_report_calls: list[dict[str, object]] = []
  62. def fake_report(*, candles, leverage, output_file, symbol, bar):
  63. report_calls.append(
  64. {
  65. "candles": candles,
  66. "leverage": leverage,
  67. "output_file": output_file,
  68. "symbol": symbol,
  69. "bar": bar,
  70. }
  71. )
  72. return {
  73. "report_file": str(output_file),
  74. "plot_file": str(output_file).replace(".html", ".plot.html"),
  75. "trade_count": 3,
  76. "total_return": 0.12,
  77. }
  78. def fake_bbmr_report(*, candles, leverage, output_file, symbol, bar, segments, window_size):
  79. bbmr_report_calls.append(
  80. {
  81. "candles": candles,
  82. "leverage": leverage,
  83. "output_file": output_file,
  84. "symbol": symbol,
  85. "bar": bar,
  86. "segments": segments,
  87. "window_size": window_size,
  88. }
  89. )
  90. return {
  91. "report_file": str(output_file),
  92. "segment_count": segments,
  93. "window_size": window_size,
  94. "aggregate_trade_count": 11,
  95. "average_return": 0.031,
  96. }
  97. def fake_bbsb_report(*, candles, leverage, output_file, symbol, bar, segments, window_size):
  98. bbsb_report_calls.append(
  99. {
  100. "candles": candles,
  101. "leverage": leverage,
  102. "output_file": output_file,
  103. "symbol": symbol,
  104. "bar": bar,
  105. "segments": segments,
  106. "window_size": window_size,
  107. }
  108. )
  109. return {
  110. "report_file": str(output_file),
  111. "segment_count": segments,
  112. "window_size": window_size,
  113. "aggregate_trade_count": 11,
  114. "average_return": 0.031,
  115. }
  116. def fake_donchian_report(
  117. *,
  118. candles,
  119. leverage,
  120. output_file,
  121. symbol,
  122. bar,
  123. segments,
  124. window_size,
  125. entry_window,
  126. exit_window,
  127. stop_loss_pct,
  128. ):
  129. donchian_report_calls.append(
  130. {
  131. "candles": candles,
  132. "leverage": leverage,
  133. "output_file": output_file,
  134. "symbol": symbol,
  135. "bar": bar,
  136. "segments": segments,
  137. "window_size": window_size,
  138. "entry_window": entry_window,
  139. "exit_window": exit_window,
  140. "stop_loss_pct": stop_loss_pct,
  141. }
  142. )
  143. return {
  144. "report_file": str(output_file),
  145. "segment_count": segments,
  146. "window_size": window_size,
  147. "aggregate_trade_count": 7,
  148. "average_return": 0.024,
  149. }
  150. main = main_factory(
  151. load_config=lambda: sample_config(),
  152. client_factory=lambda: client,
  153. analyze_fn=fake_analyze_with_codex,
  154. write_text=real_write_text,
  155. state_path=Path("paper_state.json") if state_path is None else state_path,
  156. now_fn=lambda: "1970-01-01T00:00:00Z",
  157. report_fn=fake_report,
  158. bbmr_report_fn=fake_bbmr_report,
  159. bbsb_report_fn=fake_bbsb_report,
  160. donchian_report_fn=fake_donchian_report,
  161. ema_pullback_report_fn=lambda **kwargs: {},
  162. )
  163. return main, client, report_calls, bbmr_report_calls, bbsb_report_calls, donchian_report_calls
  164. def test_fetch_history_prints_candle_json(capsys):
  165. main, client, _, _, _, _ = build_main_with_stubs()
  166. expected = [asdict(candle) for candle in sample_candles(limit=20)]
  167. exit_code = main(["fetch-history", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "20"])
  168. assert exit_code == 0
  169. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 20)
  170. assert json.loads(capsys.readouterr().out) == expected
  171. def test_backtest_prints_summary_json(capsys):
  172. main, client, _, _, _, _ = build_main_with_stubs()
  173. expected = run_backtest(candles=sample_candles(limit=50), leverage=2).to_dict()
  174. exit_code = main(["backtest", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "50", "--leverage", "2"])
  175. assert exit_code == 0
  176. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 50)
  177. assert json.loads(capsys.readouterr().out) == expected
  178. def test_analyze_writes_output_file_and_stdout(tmp_path, capsys):
  179. main, client, _, _, _, _ = build_main_with_stubs()
  180. output_file = tmp_path / "signal.json"
  181. exit_code = main(
  182. [
  183. "analyze",
  184. "--symbol",
  185. "BTC-USDT-SWAP",
  186. "--bar",
  187. "1H",
  188. "--limit",
  189. "20",
  190. "--output-file",
  191. str(output_file),
  192. ]
  193. )
  194. assert exit_code == 0
  195. assert output_file.exists()
  196. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 20)
  197. stdout = capsys.readouterr().out.strip()
  198. file_text = output_file.read_text()
  199. assert stdout == file_text
  200. assert json.loads(stdout) == valid_signal()
  201. def test_paper_order_initializes_local_state_and_outputs_local_order_json(tmp_path, capsys):
  202. state_path = tmp_path / "paper_state.json"
  203. main, client, _, _, _, _ = build_main_with_stubs(state_path=state_path)
  204. signal_file = tmp_path / "signal.json"
  205. signal_file.write_text(json.dumps(valid_signal()))
  206. exit_code = main(
  207. [
  208. "paper-order",
  209. "--symbol",
  210. "BTC-USDT-SWAP",
  211. "--signal-file",
  212. str(signal_file),
  213. "--margin-usdt",
  214. "100",
  215. ]
  216. )
  217. assert exit_code == 0
  218. assert client.get_last_price_called_with is None
  219. payload = json.loads(capsys.readouterr().out)
  220. assert payload == {
  221. "status": "filled",
  222. "symbol": "BTC-USDT-SWAP",
  223. "side": "long",
  224. "price": 123.5,
  225. "quantity": pytest.approx((100.0 * 2) / 123.5),
  226. "margin_used": 100.0,
  227. "cash_usdt": 9900.0,
  228. }
  229. state = json.loads(state_path.read_text())
  230. assert state["cash_usdt"] == 9900.0
  231. assert state["realized_pnl"] == 0.0
  232. assert state["updated_at"] == "1970-01-01T00:00:00Z"
  233. assert len(state["positions"]) == 1
  234. assert state["positions"][0]["symbol"] == "BTC-USDT-SWAP"
  235. assert state["positions"][0]["side"] == "long"
  236. assert state["positions"][0]["quantity"] == pytest.approx((100.0 * 2) / 123.5)
  237. assert state["positions"][0]["avg_entry_price"] == 123.5
  238. assert state["positions"][0]["margin_used"] == 100.0
  239. def test_paper_order_uses_latest_price_when_entry_price_is_null(tmp_path, capsys):
  240. state_path = tmp_path / "paper_state.json"
  241. main, client, _, _, _, _ = build_main_with_stubs(state_path=state_path)
  242. signal_file = tmp_path / "signal.json"
  243. payload = valid_signal()
  244. payload["entry_price"] = None
  245. signal_file.write_text(json.dumps(payload))
  246. exit_code = main(
  247. [
  248. "paper-order",
  249. "--symbol",
  250. "BTC-USDT-SWAP",
  251. "--signal-file",
  252. str(signal_file),
  253. "--margin-usdt",
  254. "100",
  255. ]
  256. )
  257. assert exit_code == 0
  258. assert client.get_last_price_called_with == "BTC-USDT-SWAP"
  259. payload = json.loads(capsys.readouterr().out)
  260. assert payload["price"] == 250.0
  261. assert payload["quantity"] == 0.8
  262. def test_paper_order_rejects_when_local_cash_is_insufficient(tmp_path):
  263. state_path = tmp_path / "paper_state.json"
  264. state_path.write_text(
  265. json.dumps(
  266. {
  267. "cash_usdt": 50.0,
  268. "realized_pnl": 0.0,
  269. "positions": [],
  270. "updated_at": "1970-01-01T00:00:00Z",
  271. }
  272. )
  273. )
  274. main, _, _, _, _, _ = build_main_with_stubs(state_path=state_path)
  275. signal_file = tmp_path / "signal.json"
  276. signal_file.write_text(json.dumps(valid_signal()))
  277. with pytest.raises(ValueError, match="insufficient local cash"):
  278. main(
  279. [
  280. "paper-order",
  281. "--symbol",
  282. "BTC-USDT-SWAP",
  283. "--signal-file",
  284. str(signal_file),
  285. "--margin-usdt",
  286. "100",
  287. ]
  288. )
  289. def test_positions_prints_local_state_positions(tmp_path, capsys):
  290. state_path = tmp_path / "paper_state.json"
  291. state_path.write_text(
  292. json.dumps(
  293. {
  294. "cash_usdt": 9800.0,
  295. "realized_pnl": 0.0,
  296. "positions": [
  297. {
  298. "symbol": "BTC-USDT-SWAP",
  299. "side": "long",
  300. "quantity": 2.0,
  301. "avg_entry_price": 123.5,
  302. "margin_used": 200.0,
  303. },
  304. {
  305. "symbol": "ETH-USDT-SWAP",
  306. "side": "short",
  307. "quantity": 1.0,
  308. "avg_entry_price": 3000.0,
  309. "margin_used": 150.0,
  310. },
  311. ],
  312. "updated_at": "1970-01-01T00:00:00Z",
  313. }
  314. )
  315. )
  316. main, client, _, _, _, _ = build_main_with_stubs(state_path=state_path)
  317. expected = [
  318. {
  319. "symbol": "BTC-USDT-SWAP",
  320. "side": "long",
  321. "quantity": 2.0,
  322. "avg_entry_price": 123.5,
  323. "margin_used": 200.0,
  324. }
  325. ]
  326. exit_code = main(["positions", "--symbol", "BTC-USDT-SWAP"])
  327. assert exit_code == 0
  328. assert client.get_last_price_called_with is None
  329. assert json.loads(capsys.readouterr().out) == expected
  330. def test_fetch_history_does_not_require_credentials(capsys):
  331. client = fake_client()
  332. main = main_factory(
  333. load_config=lambda: (_ for _ in ()).throw(AssertionError("should not load config")),
  334. client_factory=lambda: client,
  335. analyze_fn=fake_analyze_with_codex,
  336. write_text=real_write_text,
  337. state_path=Path("paper_state.json"),
  338. now_fn=lambda: "1970-01-01T00:00:00Z",
  339. report_fn=lambda **kwargs: {},
  340. bbmr_report_fn=lambda **kwargs: {},
  341. )
  342. exit_code = main(["fetch-history", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "2"])
  343. assert exit_code == 0
  344. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 2)
  345. assert json.loads(capsys.readouterr().out) == [asdict(candle) for candle in sample_candles(limit=2)]
  346. def test_cli_rejects_unsupported_symbol():
  347. main, _, _, _, _, _ = build_main_with_stubs()
  348. with pytest.raises(SystemExit):
  349. main(["fetch-history", "--symbol", "SOL-USDT-SWAP", "--bar", "1H", "--limit", "20"])
  350. def test_cli_rejects_leverage_out_of_range():
  351. main, _, _, _, _, _ = build_main_with_stubs()
  352. with pytest.raises(SystemExit):
  353. main(["backtest", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "50", "--leverage", "4"])
  354. def test_backtest_report_generates_html_report(capsys, tmp_path):
  355. main, client, report_calls, _, _, _ = build_main_with_stubs()
  356. output_file = tmp_path / "report.html"
  357. exit_code = main(
  358. [
  359. "backtest-report",
  360. "--symbol",
  361. "BTC-USDT-SWAP",
  362. "--bar",
  363. "1H",
  364. "--limit",
  365. "50",
  366. "--leverage",
  367. "2",
  368. "--output-file",
  369. str(output_file),
  370. ]
  371. )
  372. assert exit_code == 0
  373. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 50)
  374. assert len(report_calls) == 1
  375. assert report_calls[0]["leverage"] == 2
  376. assert report_calls[0]["output_file"] == output_file
  377. assert report_calls[0]["symbol"] == "BTC-USDT-SWAP"
  378. assert report_calls[0]["bar"] == "1H"
  379. assert json.loads(capsys.readouterr().out) == {
  380. "report_file": str(output_file),
  381. "plot_file": str(output_file).replace(".html", ".plot.html"),
  382. "trade_count": 3,
  383. "total_return": 0.12,
  384. }
  385. def test_backtest_bbmr_report_generates_single_page_report(capsys, tmp_path):
  386. main, client, _, bbmr_report_calls, _, _ = build_main_with_stubs()
  387. output_file = tmp_path / "bbmr.html"
  388. exit_code = main(
  389. [
  390. "backtest-bbmr-report",
  391. "--symbol",
  392. "BTC-USDT-SWAP",
  393. "--bar",
  394. "3m",
  395. "--history-limit",
  396. "5000",
  397. "--leverage",
  398. "2",
  399. "--segments",
  400. "8",
  401. "--window-size",
  402. "300",
  403. "--output-file",
  404. str(output_file),
  405. ]
  406. )
  407. assert exit_code == 0
  408. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
  409. assert len(bbmr_report_calls) == 1
  410. assert bbmr_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
  411. assert bbmr_report_calls[0]["bar"] == "3m"
  412. assert bbmr_report_calls[0]["segments"] == 8
  413. assert bbmr_report_calls[0]["window_size"] == 300
  414. assert json.loads(capsys.readouterr().out) == {
  415. "report_file": str(output_file),
  416. "segment_count": 8,
  417. "window_size": 300,
  418. "aggregate_trade_count": 11,
  419. "average_return": 0.031,
  420. }
  421. def test_backtest_bbsb_report_generates_single_page_report(capsys, tmp_path):
  422. main, client, _, _, bbsb_report_calls, _ = build_main_with_stubs()
  423. output_file = tmp_path / "bbsb.html"
  424. exit_code = main(
  425. [
  426. "backtest-bbsb-report",
  427. "--symbol",
  428. "BTC-USDT-SWAP",
  429. "--bar",
  430. "3m",
  431. "--history-limit",
  432. "5000",
  433. "--leverage",
  434. "2",
  435. "--segments",
  436. "8",
  437. "--window-size",
  438. "300",
  439. "--output-file",
  440. str(output_file),
  441. ]
  442. )
  443. assert exit_code == 0
  444. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
  445. assert len(bbsb_report_calls) == 1
  446. assert bbsb_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
  447. assert bbsb_report_calls[0]["bar"] == "3m"
  448. assert bbsb_report_calls[0]["segments"] == 8
  449. assert bbsb_report_calls[0]["window_size"] == 300
  450. assert json.loads(capsys.readouterr().out) == {
  451. "report_file": str(output_file),
  452. "segment_count": 8,
  453. "window_size": 300,
  454. "aggregate_trade_count": 11,
  455. "average_return": 0.031,
  456. }
  457. def test_backtest_donchian_report_dispatches_generator(capsys, tmp_path):
  458. main, client, _, _, _, donchian_report_calls = build_main_with_stubs()
  459. output_file = tmp_path / "donchian.html"
  460. exit_code = main(
  461. [
  462. "backtest-donchian-report",
  463. "--symbol",
  464. "BTC-USDT-SWAP",
  465. "--bar",
  466. "3m",
  467. "--history-limit",
  468. "5000",
  469. "--leverage",
  470. "2",
  471. "--segments",
  472. "8",
  473. "--window-size",
  474. "300",
  475. "--entry-window",
  476. "30",
  477. "--exit-window",
  478. "12",
  479. "--stop-loss-pct",
  480. "0.02",
  481. "--output-file",
  482. str(output_file),
  483. ]
  484. )
  485. assert exit_code == 0
  486. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
  487. assert len(donchian_report_calls) == 1
  488. assert donchian_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
  489. assert donchian_report_calls[0]["bar"] == "3m"
  490. assert donchian_report_calls[0]["segments"] == 8
  491. assert donchian_report_calls[0]["window_size"] == 300
  492. assert donchian_report_calls[0]["entry_window"] == 30
  493. assert donchian_report_calls[0]["exit_window"] == 12
  494. assert donchian_report_calls[0]["stop_loss_pct"] == pytest.approx(0.02)
  495. assert json.loads(capsys.readouterr().out) == {
  496. "report_file": str(output_file),
  497. "segment_count": 8,
  498. "window_size": 300,
  499. "aggregate_trade_count": 7,
  500. "average_return": 0.024,
  501. }
  502. def test_backtest_rsi2_report_dispatches_generator(capsys, tmp_path):
  503. client = fake_client()
  504. rsi2_report_calls: list[dict[str, object]] = []
  505. def fake_rsi2_report(
  506. *,
  507. candles,
  508. leverage,
  509. output_file,
  510. symbol,
  511. bar,
  512. segments,
  513. window_size,
  514. trend_sma,
  515. rsi_length,
  516. rsi_long_threshold,
  517. rsi_short_threshold,
  518. exit_rsi,
  519. ):
  520. rsi2_report_calls.append(
  521. {
  522. "candles": candles,
  523. "leverage": leverage,
  524. "output_file": output_file,
  525. "symbol": symbol,
  526. "bar": bar,
  527. "segments": segments,
  528. "window_size": window_size,
  529. "trend_sma": trend_sma,
  530. "rsi_length": rsi_length,
  531. "rsi_long_threshold": rsi_long_threshold,
  532. "rsi_short_threshold": rsi_short_threshold,
  533. "exit_rsi": exit_rsi,
  534. }
  535. )
  536. return {
  537. "report_file": str(output_file),
  538. "segment_count": segments,
  539. "window_size": window_size,
  540. "aggregate_trade_count": 5,
  541. "average_return": 0.019,
  542. }
  543. main = main_factory(
  544. load_config=lambda: sample_config(),
  545. client_factory=lambda: client,
  546. analyze_fn=fake_analyze_with_codex,
  547. write_text=real_write_text,
  548. state_path=Path("paper_state.json"),
  549. now_fn=lambda: "1970-01-01T00:00:00Z",
  550. report_fn=lambda **kwargs: {},
  551. bbmr_report_fn=lambda **kwargs: {},
  552. bbsb_report_fn=lambda **kwargs: {},
  553. donchian_report_fn=lambda **kwargs: {},
  554. rsi2_report_fn=fake_rsi2_report,
  555. )
  556. output_file = tmp_path / "rsi2.html"
  557. exit_code = main(
  558. [
  559. "backtest-rsi2-report",
  560. "--symbol",
  561. "BTC-USDT-SWAP",
  562. "--bar",
  563. "3m",
  564. "--history-limit",
  565. "5000",
  566. "--leverage",
  567. "2",
  568. "--segments",
  569. "8",
  570. "--window-size",
  571. "300",
  572. "--trend-sma",
  573. "30",
  574. "--rsi-length",
  575. "3",
  576. "--rsi-long-threshold",
  577. "15",
  578. "--rsi-short-threshold",
  579. "85",
  580. "--exit-rsi",
  581. "55",
  582. "--output-file",
  583. str(output_file),
  584. ]
  585. )
  586. assert exit_code == 0
  587. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
  588. assert len(rsi2_report_calls) == 1
  589. assert rsi2_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
  590. assert rsi2_report_calls[0]["bar"] == "3m"
  591. assert rsi2_report_calls[0]["segments"] == 8
  592. assert rsi2_report_calls[0]["window_size"] == 300
  593. assert rsi2_report_calls[0]["trend_sma"] == 30
  594. assert rsi2_report_calls[0]["rsi_length"] == 3
  595. assert rsi2_report_calls[0]["rsi_long_threshold"] == pytest.approx(15.0)
  596. assert rsi2_report_calls[0]["rsi_short_threshold"] == pytest.approx(85.0)
  597. assert rsi2_report_calls[0]["exit_rsi"] == pytest.approx(55.0)
  598. assert json.loads(capsys.readouterr().out) == {
  599. "report_file": str(output_file),
  600. "segment_count": 8,
  601. "window_size": 300,
  602. "aggregate_trade_count": 5,
  603. "average_return": 0.019,
  604. }
  605. def test_backtest_ema_pullback_report_dispatches_generator(capsys, tmp_path):
  606. client = fake_client()
  607. ema_pullback_report_calls: list[dict[str, object]] = []
  608. def fake_ema_pullback_report(
  609. *,
  610. candles,
  611. leverage,
  612. output_file,
  613. symbol,
  614. bar,
  615. segments,
  616. window_size,
  617. fast_ema,
  618. slow_ema,
  619. stop_buffer_pct,
  620. ):
  621. ema_pullback_report_calls.append(
  622. {
  623. "candles": candles,
  624. "leverage": leverage,
  625. "output_file": output_file,
  626. "symbol": symbol,
  627. "bar": bar,
  628. "segments": segments,
  629. "window_size": window_size,
  630. "fast_ema": fast_ema,
  631. "slow_ema": slow_ema,
  632. "stop_buffer_pct": stop_buffer_pct,
  633. }
  634. )
  635. return {
  636. "report_file": str(output_file),
  637. "segment_count": segments,
  638. "window_size": window_size,
  639. "aggregate_trade_count": 6,
  640. "average_return": 0.021,
  641. }
  642. main = main_factory(
  643. load_config=lambda: sample_config(),
  644. client_factory=lambda: client,
  645. analyze_fn=fake_analyze_with_codex,
  646. write_text=real_write_text,
  647. state_path=Path("paper_state.json"),
  648. now_fn=lambda: "1970-01-01T00:00:00Z",
  649. report_fn=lambda **kwargs: {},
  650. bbmr_report_fn=lambda **kwargs: {},
  651. bbsb_report_fn=lambda **kwargs: {},
  652. donchian_report_fn=lambda **kwargs: {},
  653. rsi2_report_fn=lambda **kwargs: {},
  654. ema_pullback_report_fn=fake_ema_pullback_report,
  655. )
  656. output_file = tmp_path / "ema-pullback.html"
  657. exit_code = main(
  658. [
  659. "backtest-ema-pullback-report",
  660. "--symbol",
  661. "BTC-USDT-SWAP",
  662. "--bar",
  663. "3m",
  664. "--history-limit",
  665. "5000",
  666. "--leverage",
  667. "2",
  668. "--segments",
  669. "8",
  670. "--window-size",
  671. "300",
  672. "--fast-ema",
  673. "30",
  674. "--slow-ema",
  675. "80",
  676. "--stop-buffer-pct",
  677. "0.01",
  678. "--output-file",
  679. str(output_file),
  680. ]
  681. )
  682. assert exit_code == 0
  683. assert client.get_candles_called_with == ("BTC-USDT-SWAP", "3m", 5000)
  684. assert len(ema_pullback_report_calls) == 1
  685. assert ema_pullback_report_calls[0]["symbol"] == "BTC-USDT-SWAP"
  686. assert ema_pullback_report_calls[0]["bar"] == "3m"
  687. assert ema_pullback_report_calls[0]["segments"] == 8
  688. assert ema_pullback_report_calls[0]["window_size"] == 300
  689. assert ema_pullback_report_calls[0]["fast_ema"] == 30
  690. assert ema_pullback_report_calls[0]["slow_ema"] == 80
  691. assert ema_pullback_report_calls[0]["stop_buffer_pct"] == pytest.approx(0.01)
  692. assert json.loads(capsys.readouterr().out) == {
  693. "report_file": str(output_file),
  694. "segment_count": 8,
  695. "window_size": 300,
  696. "aggregate_trade_count": 6,
  697. "average_return": 0.021,
  698. }