import subprocess import pytest from okx_codex_trader.codex_analyzer import analyze_with_codex from okx_codex_trader.models import Candle def sample_candles() -> list[Candle]: return [ Candle(symbol="BTC-USDT-SWAP", ts=1, open=100.0, high=105.0, low=99.0, close=104.0, volume=10.0), Candle(symbol="BTC-USDT-SWAP", ts=2, open=104.0, high=106.0, low=103.0, close=105.0, volume=12.0), Candle(symbol="BTC-USDT-SWAP", ts=3, open=105.0, high=107.0, low=104.0, close=106.0, volume=11.0), ] def fake_runner(stdout: str, returncode: int = 0): calls: list[tuple[object, bool, bool, bool]] = [] def runner(command, capture_output: bool, text: bool, check: bool): calls.append((command, capture_output, text, check)) return subprocess.CompletedProcess(command, returncode, stdout=stdout, stderr="") runner.calls = calls return runner def fake_which(path: str = "/tmp/fake-codex"): calls: list[str] = [] def which(name: str) -> str: calls.append(name) return path which.calls = calls return which def missing_which(name: str) -> None: assert name == "codex" return None def test_analyzer_fails_when_codex_is_missing(): with pytest.raises(FileNotFoundError): analyze_with_codex(candles=sample_candles(), symbol="BTC-USDT-SWAP", bar="1H", which=missing_which) def test_analyzer_rejects_non_json_output(): runner = fake_runner(stdout="not json") which = fake_which() with pytest.raises(ValueError): analyze_with_codex(candles=sample_candles(), symbol="BTC-USDT-SWAP", bar="1H", runner=runner, which=which) assert which.calls == ["codex"] def test_analyzer_rejects_json_leverage_out_of_range(): runner = fake_runner( stdout='{"action":"long","confidence":0.8,"leverage":4,"entry_price":null,"take_profit_price":null,"stop_loss_price":null,"reason":"x"}' ) which = fake_which() with pytest.raises(ValueError): analyze_with_codex(candles=sample_candles(), symbol="BTC-USDT-SWAP", bar="1H", runner=runner, which=which) assert which.calls == ["codex"] def test_analyzer_rejects_non_zero_exit_with_non_json_stdout(): runner = fake_runner(stdout="not json", returncode=1) which = fake_which() with pytest.raises(ValueError, match="codex execution failed"): analyze_with_codex(candles=sample_candles(), symbol="BTC-USDT-SWAP", bar="1H", runner=runner, which=which) assert which.calls == ["codex"] def test_analyzer_rejects_non_zero_exit_with_json_stdout(): runner = fake_runner( stdout='{"action":"long","confidence":0.8,"leverage":2,"entry_price":null,"take_profit_price":null,"stop_loss_price":null,"reason":"x"}', returncode=1, ) which = fake_which() with pytest.raises(ValueError, match="codex execution failed"): analyze_with_codex(candles=sample_candles(), symbol="BTC-USDT-SWAP", bar="1H", runner=runner, which=which) assert which.calls == ["codex"] def test_analyzer_returns_valid_trade_signal(): runner = fake_runner( stdout='{"action":"short","confidence":0.6,"leverage":2,"entry_price":101.5,"take_profit_price":99.0,"stop_loss_price":103.0,"reason":"trend"}' ) which = fake_which() signal = analyze_with_codex( candles=sample_candles(), symbol="BTC-USDT-SWAP", bar="1H", runner=runner, which=which, ) assert signal.action == "short" assert signal.confidence == 0.6 assert signal.leverage == 2 assert signal.entry_price == 101.5 assert signal.take_profit_price == 99.0 assert signal.stop_loss_price == 103.0 assert signal.reason == "trend" assert which.calls == ["codex"] assert runner.calls assert runner.calls[0][0][0:2] == ["codex", "exec"] assert runner.calls[0][1:] == (True, True, False) prompt = runner.calls[0][0][2] assert "return exactly one JSON object" in prompt assert "Do not output markdown, prose, or code fences." in prompt assert '"action", "confidence", "leverage", "entry_price", "take_profit_price", "stop_loss_price", "reason"' in prompt assert 'Valid actions are "long", "short", and "flat".' in prompt assert 'candles: [{"symbol":"BTC-USDT-SWAP"' in prompt