Просмотр исходного кода

feat: add freqtrade comparison experiment

lxy 1 месяц назад
Родитель
Сommit
c457ffdcfd

+ 2 - 0
.gitignore

@@ -6,5 +6,7 @@ __pycache__/
 .superpowers/
 
 data/
+freqtrade/user_data/data/
+freqtrade/user_data/backtest_results/
 paper_state.json
 signal.json

+ 28 - 0
freqtrade/README.md

@@ -0,0 +1,28 @@
+# Freqtrade Experiment
+
+This directory keeps Freqtrade isolated from the main OKX research code. It is for cross-checking one strategy first: `BTC RSI2 Guarded 15m`.
+
+Export cached OKX candles:
+
+```bash
+uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 15m
+```
+
+Run with a local Freqtrade installation:
+
+```bash
+freqtrade backtesting \
+  --config freqtrade/config-okx-futures.json \
+  --userdir freqtrade/user_data \
+  --strategy BtcRsi2Guarded \
+  --timeframe 15m \
+  --timerange 20230101-
+```
+
+The first comparison target is the existing report row:
+
+```text
+BTC RSI2 Guarded 15m, 3y return around 102.88% before Freqtrade model differences.
+```
+
+Freqtrade's accounting model is not identical to the current research script. Treat this as an execution-framework comparison, not a byte-for-byte backtest replacement.

+ 57 - 0
freqtrade/config-okx-futures.json

@@ -0,0 +1,57 @@
+{
+  "$schema": "https://schema.freqtrade.io/schema.json",
+  "max_open_trades": 1,
+  "stake_currency": "USDT",
+  "stake_amount": 10,
+  "tradable_balance_ratio": 0.99,
+  "dry_run": true,
+  "dry_run_wallet": 1000,
+  "trading_mode": "futures",
+  "margin_mode": "isolated",
+  "timeframe": "15m",
+  "dataformat_ohlcv": "json",
+  "exchange": {
+    "name": "okx",
+    "key": "",
+    "secret": "",
+    "password": "",
+    "ccxt_config": {},
+    "ccxt_async_config": {},
+    "pair_whitelist": [
+      "BTC/USDT:USDT"
+    ],
+    "pair_blacklist": []
+  },
+  "pairlists": [
+    {
+      "method": "StaticPairList"
+    }
+  ],
+  "entry_pricing": {
+    "price_side": "same",
+    "use_order_book": false
+  },
+  "exit_pricing": {
+    "price_side": "same",
+    "use_order_book": false
+  },
+  "order_types": {
+    "entry": "market",
+    "exit": "market",
+    "emergency_exit": "market",
+    "force_entry": "market",
+    "force_exit": "market",
+    "stoploss": "market",
+    "stoploss_on_exchange": false
+  },
+  "unfilledtimeout": {
+    "entry": 10,
+    "exit": 10,
+    "unit": "minutes"
+  },
+  "bot_name": "okx-codex-btc-rsi2-guarded",
+  "initial_state": "stopped",
+  "internals": {
+    "process_throttle_secs": 5
+  }
+}

+ 94 - 0
freqtrade/user_data/strategies/BtcRsi2Guarded.py

@@ -0,0 +1,94 @@
+from datetime import datetime
+
+import pandas as pd
+from freqtrade.persistence import Trade
+from freqtrade.strategy import IStrategy
+
+
+class BtcRsi2Guarded(IStrategy):
+    INTERFACE_VERSION = 3
+
+    timeframe = "15m"
+    can_short = False
+    startup_candle_count = 240
+    process_only_new_candles = True
+
+    minimal_roi = {"0": 100.0}
+    stoploss = -0.024
+    use_exit_signal = True
+    exit_profit_only = False
+    ignore_roi_if_entry_signal = False
+
+    trend_sma = 240
+    rsi_length = 2
+    rsi_threshold = 2.0
+    exit_rsi = 55.0
+    max_hold_bars = 48
+    leverage_value = 3.0
+
+    def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe["trend"] = dataframe["close"].rolling(self.trend_sma).mean()
+        dataframe["rsi2"] = self._rsi(dataframe["close"], self.rsi_length)
+        return dataframe
+
+    def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe.loc[
+            (dataframe["close"] > dataframe["trend"]) & (dataframe["rsi2"] <= self.rsi_threshold),
+            ["enter_long", "enter_tag"],
+        ] = (1, "rsi2_guarded")
+        return dataframe
+
+    def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
+        dataframe.loc[dataframe["rsi2"] >= self.exit_rsi, ["exit_long", "exit_tag"]] = (1, "rsi_exit")
+        return dataframe
+
+    def custom_exit(
+        self,
+        pair: str,
+        trade: Trade,
+        current_time: datetime,
+        current_rate: float,
+        current_profit: float,
+        **kwargs,
+    ) -> str | None:
+        held_bars = int((current_time - trade.open_date_utc).total_seconds() // (15 * 60))
+        if held_bars >= self.max_hold_bars:
+            return "max_hold"
+        return None
+
+    def leverage(
+        self,
+        pair: str,
+        current_time: datetime,
+        current_rate: float,
+        proposed_leverage: float,
+        max_leverage: float,
+        entry_tag: str | None,
+        side: str,
+        **kwargs,
+    ) -> float:
+        return min(self.leverage_value, max_leverage)
+
+    @staticmethod
+    def _rsi(close: pd.Series, length: int) -> pd.Series:
+        deltas = close.diff()
+        gains = deltas.clip(lower=0.0)
+        losses = -deltas.clip(upper=0.0)
+        values = [float("nan")] * len(close)
+        if len(close) <= length:
+            return pd.Series(values, index=close.index)
+
+        average_gain = float(gains.iloc[1 : length + 1].mean())
+        average_loss = float(losses.iloc[1 : length + 1].mean())
+        for index in range(length, len(close)):
+            if index > length:
+                average_gain = ((average_gain * (length - 1)) + float(gains.iloc[index])) / length
+                average_loss = ((average_loss * (length - 1)) + float(losses.iloc[index])) / length
+            if pd.isna(average_gain) or pd.isna(average_loss):
+                continue
+            if average_loss == 0.0:
+                values[index] = 100.0 if average_gain > 0.0 else 50.0
+                continue
+            relative_strength = average_gain / average_loss
+            values[index] = 100.0 - (100.0 / (1.0 + relative_strength))
+        return pd.Series(values, index=close.index)

+ 54 - 0
scripts/export_freqtrade_data.py

@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+
+ROOT_DIR = Path(__file__).resolve().parents[1]
+if str(ROOT_DIR) not in sys.path:
+    sys.path.insert(0, str(ROOT_DIR))
+
+from scripts.explore_ultrashort import CANDLE_CACHE_DIR, load_cached_candles
+
+
+PAIR_FILENAMES = {
+    "BTC-USDT-SWAP": "BTC_USDT_USDT",
+    "ETH-USDT-SWAP": "ETH_USDT_USDT",
+}
+
+
+def export_ohlcv_json(*, symbol: str, bar: str, output_dir: Path, cache_dir: Path = CANDLE_CACHE_DIR) -> Path:
+    candles, _ = load_cached_candles(cache_dir, symbol, bar)
+    if not candles:
+        raise RuntimeError(f"missing cached candles: {symbol} {bar}")
+    output_dir.mkdir(parents=True, exist_ok=True)
+    output_file = output_dir / f"{PAIR_FILENAMES[symbol]}-{bar}-futures.json"
+    rows = [
+        [candle.ts, candle.open, candle.high, candle.low, candle.close, candle.volume]
+        for candle in candles
+    ]
+    output_file.write_text(json.dumps(rows, separators=(",", ":")), encoding="utf-8")
+    return output_file
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--symbol", choices=tuple(PAIR_FILENAMES), default="BTC-USDT-SWAP")
+    parser.add_argument("--bar", default="15m")
+    parser.add_argument("--cache-dir", type=Path, default=CANDLE_CACHE_DIR)
+    parser.add_argument("--output-dir", type=Path, default=Path("freqtrade/user_data/data/okx/futures"))
+    args = parser.parse_args()
+
+    output_file = export_ohlcv_json(
+        symbol=args.symbol,
+        bar=args.bar,
+        cache_dir=args.cache_dir,
+        output_dir=args.output_dir,
+    )
+    print(output_file)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 46 - 0
tests/test_export_freqtrade_data.py

@@ -0,0 +1,46 @@
+import importlib.util
+import json
+import sys
+from pathlib import Path
+
+from okx_codex_trader.models import Candle
+
+
+def load_export_module():
+    path = Path(__file__).resolve().parents[1] / "scripts" / "export_freqtrade_data.py"
+    spec = importlib.util.spec_from_file_location("export_freqtrade_data", path)
+    assert spec is not None
+    module = importlib.util.module_from_spec(spec)
+    assert spec.loader is not None
+    sys.modules[spec.name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def test_export_ohlcv_json_writes_freqtrade_futures_filename(tmp_path):
+    module = load_export_module()
+    cache_dir = tmp_path / "cache"
+    explore = __import__("scripts.explore_ultrashort", fromlist=["save_cached_candles"])
+    explore.save_cached_candles(
+        cache_dir,
+        "BTC-USDT-SWAP",
+        "15m",
+        [
+            Candle("BTC-USDT-SWAP", 1_700_000_000_000, 100.0, 101.0, 99.0, 100.5, 10.0),
+            Candle("BTC-USDT-SWAP", 1_700_000_900_000, 100.5, 102.0, 100.0, 101.5, 11.0),
+        ],
+        history_exhausted=True,
+    )
+
+    output_file = module.export_ohlcv_json(
+        symbol="BTC-USDT-SWAP",
+        bar="15m",
+        cache_dir=cache_dir,
+        output_dir=tmp_path / "freqtrade",
+    )
+
+    assert output_file.name == "BTC_USDT_USDT-15m-futures.json"
+    assert json.loads(output_file.read_text()) == [
+        [1_700_000_000_000, 100.0, 101.0, 99.0, 100.5, 10.0],
+        [1_700_000_900_000, 100.5, 102.0, 100.0, 101.5, 11.0],
+    ]