test_cli.py 28 KB

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