Procházet zdrojové kódy

feat: add cli commands for okx codex trader

lxy před 1 měsícem
rodič
revize
79ad074392
3 změnil soubory, kde provedl 314 přidání a 0 odebrání
  1. 17 0
      README.md
  2. 104 0
      okx_codex_trader/cli.py
  3. 193 0
      tests/test_cli.py

+ 17 - 0
README.md

@@ -1,3 +1,20 @@
 # okx-codex-trader
 
 Minimal project skeleton for the OKX Codex Trader demo CLI.
+
+## CLI usage
+
+```bash
+python -m okx_codex_trader.cli fetch-history --symbol BTC-USDT-SWAP --bar 1H --limit 50
+python -m okx_codex_trader.cli backtest --symbol BTC-USDT-SWAP --bar 1H --limit 200 --leverage 2
+python -m okx_codex_trader.cli analyze --symbol BTC-USDT-SWAP --bar 1H --limit 50 --output-file signal.json
+python -m okx_codex_trader.cli paper-order --symbol BTC-USDT-SWAP --signal-file signal.json --margin-usdt 100
+python -m okx_codex_trader.cli positions --symbol BTC-USDT-SWAP
+```
+
+Supported symbols are `BTC-USDT-SWAP` and `ETH-USDT-SWAP`. Backtest leverage is restricted to `1`, `2`, or `3`.
+
+## Verification notes
+
+- OKX demo credentials were not exercised in automated tests.
+- Local `codex` runtime behavior outside mocked subprocess tests still requires manual verification.

+ 104 - 0
okx_codex_trader/cli.py

@@ -0,0 +1,104 @@
+import argparse
+import json
+from dataclasses import asdict
+from pathlib import Path
+from typing import Callable, Sequence
+
+from okx_codex_trader.backtest import run_backtest
+from okx_codex_trader.codex_analyzer import analyze_with_codex
+from okx_codex_trader.config import Config, load_config
+from okx_codex_trader.okx_client import OkxClient
+from okx_codex_trader.strategy import validate_signal
+
+
+SUPPORTED_SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(prog="okx-codex-trader")
+    subparsers = parser.add_subparsers(dest="command", required=True)
+
+    fetch_history = subparsers.add_parser("fetch-history")
+    fetch_history.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+    fetch_history.add_argument("--bar", required=True)
+    fetch_history.add_argument("--limit", type=int, required=True)
+
+    backtest = subparsers.add_parser("backtest")
+    backtest.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+    backtest.add_argument("--bar", required=True)
+    backtest.add_argument("--limit", type=int, required=True)
+    backtest.add_argument("--leverage", type=int, choices=(1, 2, 3), required=True)
+
+    analyze = subparsers.add_parser("analyze")
+    analyze.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+    analyze.add_argument("--bar", required=True)
+    analyze.add_argument("--limit", type=int, required=True)
+    analyze.add_argument("--output-file", required=True)
+
+    paper_order = subparsers.add_parser("paper-order")
+    paper_order.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+    paper_order.add_argument("--signal-file", required=True)
+    paper_order.add_argument("--margin-usdt", type=float, required=True)
+
+    positions = subparsers.add_parser("positions")
+    positions.add_argument("--symbol", choices=SUPPORTED_SYMBOLS, required=True)
+
+    return parser
+
+
+def _write_text(path: str, text: str) -> None:
+    Path(path).write_text(text)
+
+
+def _dump_json(payload: object) -> str:
+    return json.dumps(payload, indent=2)
+
+
+def main_factory(
+    *,
+    load_config: Callable[[], Config] = load_config,
+    client_factory: Callable[[Config], OkxClient] = OkxClient,
+    analyze_fn: Callable = analyze_with_codex,
+    write_text: Callable[[str, str], None] = _write_text,
+):
+    def main(argv: Sequence[str] | None = None) -> int:
+        parser = build_parser()
+        args = parser.parse_args(argv)
+        config = load_config()
+        client = client_factory(config)
+
+        if args.command == "fetch-history":
+            candles = client.get_candles(args.symbol, args.bar, args.limit)
+            print(_dump_json([asdict(candle) for candle in candles]))
+            return 0
+
+        if args.command == "backtest":
+            candles = client.get_candles(args.symbol, args.bar, args.limit)
+            print(_dump_json(run_backtest(candles=candles, leverage=args.leverage).to_dict()))
+            return 0
+
+        if args.command == "analyze":
+            candles = client.get_candles(args.symbol, args.bar, args.limit)
+            signal = analyze_fn(candles=candles, symbol=args.symbol, bar=args.bar)
+            output = _dump_json(asdict(signal))
+            write_text(args.output_file, output)
+            print(output)
+            return 0
+
+        if args.command == "paper-order":
+            signal = validate_signal(json.loads(Path(args.signal_file).read_text()))
+            print(_dump_json(asdict(client.place_demo_order(args.symbol, signal, args.margin_usdt))))
+            return 0
+
+        positions = client.get_positions(args.symbol)
+        print(_dump_json([asdict(position) for position in positions]))
+        return 0
+
+    return main
+
+
+main = main_factory()
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 193 - 0
tests/test_cli.py

@@ -0,0 +1,193 @@
+import json
+from dataclasses import asdict
+from pathlib import Path
+
+import pytest
+
+from okx_codex_trader.cli import main_factory
+from okx_codex_trader.config import Config
+from okx_codex_trader.models import Candle, OrderResult, Position, TradeSignal
+
+
+def sample_config() -> Config:
+    return Config(api_key="key", api_secret="secret", api_passphrase="passphrase")
+
+
+def sample_candles(limit: int = 60, symbol: str = "BTC-USDT-SWAP") -> list[Candle]:
+    candles = []
+    for index in range(limit):
+        price = 100.0 + index
+        candles.append(
+            Candle(
+                symbol=symbol,
+                ts=index,
+                open=price,
+                high=price + 1.0,
+                low=price - 1.0,
+                close=price + 0.5,
+                volume=1_000.0 + index,
+            )
+        )
+    return candles
+
+
+def valid_signal() -> dict[str, object]:
+    return {
+        "action": "long",
+        "confidence": 0.8,
+        "leverage": 2,
+        "entry_price": 123.5,
+        "take_profit_price": 130.0,
+        "stop_loss_price": 119.0,
+        "reason": "trend",
+    }
+
+
+def fake_analyze_with_codex(candles: list[Candle], symbol: str, bar: str) -> TradeSignal:
+    assert candles
+    assert symbol == "BTC-USDT-SWAP"
+    assert bar == "1H"
+    return TradeSignal(**valid_signal())
+
+
+def real_write_text(path: str, text: str) -> None:
+    Path(path).write_text(text)
+
+
+class FakeClient:
+    def __init__(self):
+        self.get_candles_called_with: tuple[str, str, int] | None = None
+        self.get_positions_called_with: str | None = None
+        self.place_demo_order_called = False
+        self.place_demo_order_called_with: tuple[str, TradeSignal, float] | None = None
+
+    def get_candles(self, symbol: str, bar: str, limit: int) -> list[Candle]:
+        self.get_candles_called_with = (symbol, bar, limit)
+        return sample_candles(limit=limit, symbol=symbol)
+
+    def place_demo_order(self, symbol: str, signal: TradeSignal, margin_usdt: float) -> OrderResult:
+        self.place_demo_order_called = True
+        self.place_demo_order_called_with = (symbol, signal, margin_usdt)
+        return OrderResult(
+            status="placed",
+            order_id="demo-order-1",
+            symbol=symbol,
+            side="buy",
+            pos_side="long",
+            order_type="limit",
+            size=1.0,
+        )
+
+    def get_positions(self, symbol: str) -> list[Position]:
+        self.get_positions_called_with = symbol
+        return [Position(symbol=symbol, pos_side="long", size=2.0, avg_price=123.5)]
+
+
+def fake_client() -> FakeClient:
+    return FakeClient()
+
+
+def build_main_with_stubs():
+    client = fake_client()
+    main = main_factory(
+        load_config=lambda: sample_config(),
+        client_factory=lambda config: client,
+        analyze_fn=fake_analyze_with_codex,
+        write_text=real_write_text,
+    )
+    return main, client
+
+
+def test_fetch_history_prints_candle_json(capsys):
+    main, client = build_main_with_stubs()
+
+    exit_code = main(["fetch-history", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "20"])
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 20)
+    assert '"symbol": "BTC-USDT-SWAP"' in capsys.readouterr().out
+
+
+def test_backtest_prints_summary_json(capsys):
+    main, client = build_main_with_stubs()
+
+    exit_code = main(["backtest", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "50", "--leverage", "2"])
+
+    assert exit_code == 0
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 50)
+    assert '"trade_count"' in capsys.readouterr().out
+
+
+def test_analyze_writes_output_file_and_stdout(tmp_path, capsys):
+    main, client = build_main_with_stubs()
+    output_file = tmp_path / "signal.json"
+
+    exit_code = main(
+        [
+            "analyze",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--bar",
+            "1H",
+            "--limit",
+            "20",
+            "--output-file",
+            str(output_file),
+        ]
+    )
+
+    assert exit_code == 0
+    assert output_file.exists()
+    assert json.loads(output_file.read_text()) == valid_signal()
+    assert client.get_candles_called_with == ("BTC-USDT-SWAP", "1H", 20)
+    assert '"action"' in capsys.readouterr().out
+
+
+def test_paper_order_reads_signal_file_and_outputs_order_json(tmp_path, capsys):
+    main, client = build_main_with_stubs()
+    signal_file = tmp_path / "signal.json"
+    signal_file.write_text(json.dumps(valid_signal()))
+
+    exit_code = main(
+        [
+            "paper-order",
+            "--symbol",
+            "BTC-USDT-SWAP",
+            "--signal-file",
+            str(signal_file),
+            "--margin-usdt",
+            "100",
+        ]
+    )
+
+    assert exit_code == 0
+    assert client.place_demo_order_called
+    assert client.place_demo_order_called_with is not None
+    assert client.place_demo_order_called_with[0] == "BTC-USDT-SWAP"
+    assert asdict(client.place_demo_order_called_with[1]) == valid_signal()
+    assert client.place_demo_order_called_with[2] == 100.0
+    assert '"status"' in capsys.readouterr().out
+
+
+def test_positions_prints_position_json(capsys):
+    main, client = build_main_with_stubs()
+
+    exit_code = main(["positions", "--symbol", "BTC-USDT-SWAP"])
+
+    assert exit_code == 0
+    assert client.get_positions_called_with == "BTC-USDT-SWAP"
+    assert '"symbol": "BTC-USDT-SWAP"' in capsys.readouterr().out
+
+
+def test_cli_rejects_unsupported_symbol():
+    main, _ = build_main_with_stubs()
+
+    with pytest.raises(SystemExit):
+        main(["fetch-history", "--symbol", "SOL-USDT-SWAP", "--bar", "1H", "--limit", "20"])
+
+
+def test_cli_rejects_leverage_out_of_range():
+    main, _ = build_main_with_stubs()
+
+    with pytest.raises(SystemExit):
+        main(["backtest", "--symbol", "BTC-USDT-SWAP", "--bar", "1H", "--limit", "50", "--leverage", "4"])