build_freqtrade_eth_informative_skeleton.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. from __future__ import annotations
  2. import json
  3. from datetime import UTC, datetime
  4. from pathlib import Path
  5. ROOT = Path(__file__).resolve().parents[1]
  6. STRATEGY_PATH = ROOT / "freqtrade" / "user_data" / "strategies" / "EthFocusedInformativeDry.py"
  7. REPORT_DIR = ROOT / "reports" / "eth-exploration"
  8. STRATEGY_SOURCE = '''from __future__ import annotations
  9. from datetime import datetime
  10. import pandas as pd
  11. from freqtrade.persistence import Trade
  12. from freqtrade.strategy import IStrategy
  13. class EthFocusedInformativeDry(IStrategy):
  14. INTERFACE_VERSION = 3
  15. timeframe = "5m"
  16. can_short = False
  17. startup_candle_count = 480
  18. process_only_new_candles = True
  19. minimal_roi = {"0": 100.0}
  20. stoploss = -0.02
  21. use_exit_signal = True
  22. exit_profit_only = False
  23. ignore_roi_if_entry_signal = False
  24. eth_rsi_trend_sma = 120
  25. eth_rsi_length = 2
  26. eth_rsi_threshold = 3.0
  27. eth_exit_rsi = 55.0
  28. btc_trend_sma = 480
  29. btc_momentum_lookback = 240
  30. btc_min_momentum = 0.0
  31. lead_lookback_15m = 8
  32. lead_lookback_5m = 16
  33. btc_return_threshold_15m = 0.018
  34. btc_return_threshold_5m = 0.012
  35. lag_gap = 0.006
  36. lead_lag_max_hold_bars = 8
  37. lead_lag_stop_loss = -0.006
  38. lead_lag_take_profit = 0.018
  39. rsi_filter_leverage = 3.0
  40. lead_lag_leverage = 3.0
  41. def informative_pairs(self) -> list[tuple[str, str]]:
  42. return [
  43. ("BTC/USDT:USDT", "5m"),
  44. ("BTC/USDT:USDT", "15m"),
  45. ("ETH/USDT:USDT", "15m"),
  46. ]
  47. def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
  48. dataframe["eth_return_5m"] = dataframe["close"].pct_change(self.lead_lookback_5m)
  49. if self.dp:
  50. btc_5m = self.dp.get_pair_dataframe(pair="BTC/USDT:USDT", timeframe="5m")
  51. btc_5m["btc_return"] = btc_5m["close"].pct_change(self.lead_lookback_5m)
  52. dataframe = self._merge_informative(dataframe, btc_5m, "btc", "5m")
  53. btc_15m = self.dp.get_pair_dataframe(pair="BTC/USDT:USDT", timeframe="15m")
  54. btc_15m["btc_trend"] = btc_15m["close"].rolling(self.btc_trend_sma).mean()
  55. btc_15m["btc_momentum"] = btc_15m["close"].pct_change(self.btc_momentum_lookback)
  56. btc_15m["btc_return"] = btc_15m["close"].pct_change(self.lead_lookback_15m)
  57. dataframe = self._merge_informative(dataframe, btc_15m, "btc", "15m")
  58. eth_15m = self.dp.get_pair_dataframe(pair=metadata["pair"], timeframe="15m")
  59. eth_15m["eth_trend"] = eth_15m["close"].rolling(self.eth_rsi_trend_sma).mean()
  60. eth_15m["eth_rsi2"] = self._rsi(eth_15m["close"], self.eth_rsi_length)
  61. eth_15m["eth_return"] = eth_15m["close"].pct_change(self.lead_lookback_15m)
  62. dataframe = self._merge_informative(dataframe, eth_15m, "eth", "15m")
  63. return dataframe
  64. def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
  65. rsi_filter = (
  66. (dataframe["eth_close_15m"] > dataframe["eth_trend_15m"])
  67. & (dataframe["eth_rsi2_15m"] <= self.eth_rsi_threshold)
  68. & (dataframe["btc_close_15m"] > dataframe["btc_trend_15m"])
  69. & (dataframe["btc_momentum_15m"] >= self.btc_min_momentum)
  70. )
  71. lead_lag_15m = (
  72. (dataframe["btc_return_15m"] >= self.btc_return_threshold_15m)
  73. & ((dataframe["btc_return_15m"] - dataframe["eth_return_15m"]) >= self.lag_gap)
  74. )
  75. lead_lag_5m = (
  76. (dataframe["btc_return_5m"] >= self.btc_return_threshold_5m)
  77. & ((dataframe["btc_return_5m"] - dataframe["eth_return_5m"]) >= self.lag_gap)
  78. )
  79. dataframe.loc[rsi_filter, ["enter_long", "enter_tag"]] = (1, "eth_btc_rsi_filter_15m")
  80. dataframe.loc[lead_lag_15m, ["enter_long", "enter_tag"]] = (1, "btc_lead_eth_lag_15m")
  81. dataframe.loc[lead_lag_5m, ["enter_long", "enter_tag"]] = (1, "btc_lead_eth_lag_5m")
  82. return dataframe
  83. def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
  84. dataframe.loc[
  85. (dataframe["eth_rsi2_15m"] >= self.eth_exit_rsi)
  86. | (dataframe["btc_close_15m"] <= dataframe["btc_trend_15m"]),
  87. ["exit_long", "exit_tag"],
  88. ] = (1, "rsi_or_btc_trend_exit")
  89. return dataframe
  90. def custom_exit(
  91. self,
  92. pair: str,
  93. trade: Trade,
  94. current_time: datetime,
  95. current_rate: float,
  96. current_profit: float,
  97. **kwargs,
  98. ) -> str | None:
  99. if trade.enter_tag not in {"btc_lead_eth_lag_15m", "btc_lead_eth_lag_5m"}:
  100. return None
  101. held_bars = int((current_time - trade.open_date_utc).total_seconds() // (5 * 60))
  102. if current_profit <= self.lead_lag_stop_loss:
  103. return "lead_lag_stop"
  104. if current_profit >= self.lead_lag_take_profit:
  105. return "lead_lag_take_profit"
  106. if held_bars >= self.lead_lag_max_hold_bars:
  107. return "lead_lag_max_hold"
  108. return None
  109. def leverage(
  110. self,
  111. pair: str,
  112. current_time: datetime,
  113. current_rate: float,
  114. proposed_leverage: float,
  115. max_leverage: float,
  116. entry_tag: str | None,
  117. side: str,
  118. **kwargs,
  119. ) -> float:
  120. if entry_tag in {"btc_lead_eth_lag_15m", "btc_lead_eth_lag_5m"}:
  121. return min(self.lead_lag_leverage, max_leverage)
  122. return min(self.rsi_filter_leverage, max_leverage)
  123. @staticmethod
  124. def _merge_informative(
  125. dataframe: pd.DataFrame,
  126. informative: pd.DataFrame,
  127. prefix: str,
  128. timeframe: str,
  129. ) -> pd.DataFrame:
  130. minutes = {"5m": 5, "15m": 15}[timeframe]
  131. informative = informative.copy()
  132. informative["merge_date"] = informative["date"] + pd.to_timedelta(minutes, unit="m")
  133. columns = ["merge_date", "open", "high", "low", "close", "volume"]
  134. columns += [column for column in informative.columns if column.startswith(f"{prefix}_")]
  135. informative = informative[columns].rename(
  136. columns={
  137. column: f"{prefix}_{column}_{timeframe}"
  138. for column in columns
  139. if column != "merge_date" and not column.startswith(f"{prefix}_")
  140. }
  141. )
  142. informative = informative.rename(
  143. columns={
  144. column: f"{column}_{timeframe}"
  145. for column in informative.columns
  146. if column.startswith(f"{prefix}_") and not column.endswith(f"_{timeframe}")
  147. }
  148. )
  149. merged = pd.merge_asof(
  150. dataframe.sort_values("date"),
  151. informative.sort_values("merge_date"),
  152. left_on="date",
  153. right_on="merge_date",
  154. direction="backward",
  155. ).ffill()
  156. return merged.drop(columns=[column for column in merged.columns if column.startswith("merge_date")])
  157. @staticmethod
  158. def _rsi(close: pd.Series, length: int) -> pd.Series:
  159. deltas = close.diff()
  160. gains = deltas.clip(lower=0.0)
  161. losses = -deltas.clip(upper=0.0)
  162. values = [float("nan")] * len(close)
  163. if len(close) <= length:
  164. return pd.Series(values, index=close.index)
  165. average_gain = float(gains.iloc[1 : length + 1].mean())
  166. average_loss = float(losses.iloc[1 : length + 1].mean())
  167. for index in range(length, len(close)):
  168. if index > length:
  169. average_gain = ((average_gain * (length - 1)) + float(gains.iloc[index])) / length
  170. average_loss = ((average_loss * (length - 1)) + float(losses.iloc[index])) / length
  171. if pd.isna(average_gain) or pd.isna(average_loss):
  172. continue
  173. if average_loss == 0.0:
  174. values[index] = 100.0 if average_gain > 0.0 else 50.0
  175. continue
  176. relative_strength = average_gain / average_loss
  177. values[index] = 100.0 - (100.0 / (1.0 + relative_strength))
  178. return pd.Series(values, index=close.index)
  179. '''
  180. def build_payload(generated_at: str) -> dict[str, object]:
  181. return {
  182. "generated_at": generated_at,
  183. "mode": "backtest_comparison_skeleton_only",
  184. "strategy": str(STRATEGY_PATH.relative_to(ROOT)),
  185. "scope": {
  186. "real_trading": False,
  187. "config_changed": False,
  188. "existing_strategy_changed": False,
  189. "base_pair": "ETH/USDT:USDT",
  190. "informative_pairs": ["BTC/USDT:USDT 5m", "BTC/USDT:USDT 15m", "ETH/USDT:USDT 15m"],
  191. "base_timeframe": "5m",
  192. },
  193. "legs": [
  194. {
  195. "tag": "eth_btc_rsi_filter_15m",
  196. "timeframe": "15m",
  197. "entry": "ETH close > ETH SMA120, ETH RSI2 <= 3, BTC close > BTC SMA480, BTC momentum240 >= 0",
  198. "exit": "ETH RSI2 >= 55 or BTC close <= BTC SMA480",
  199. "leverage": 3.0,
  200. },
  201. {
  202. "tag": "btc_lead_eth_lag_15m",
  203. "timeframe": "15m",
  204. "entry": "BTC return8 >= 0.018 and BTC return8 - ETH return8 >= 0.006",
  205. "exit": "stop -0.006, take profit 0.018, or max 8 base 5m bars",
  206. "leverage": 3.0,
  207. },
  208. {
  209. "tag": "btc_lead_eth_lag_5m",
  210. "timeframe": "5m",
  211. "entry": "BTC return16 >= 0.012 and BTC return16 - ETH return16 >= 0.006",
  212. "exit": "stop -0.006, take profit 0.018, or max 8 base 5m bars",
  213. "leverage": 3.0,
  214. },
  215. ],
  216. "data_export_commands": [
  217. "rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 5m",
  218. "rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 5m",
  219. "rtk uv run python scripts/export_freqtrade_data.py --symbol ETH-USDT-SWAP --bar 15m",
  220. "rtk uv run python scripts/export_freqtrade_data.py --symbol BTC-USDT-SWAP --bar 15m",
  221. ],
  222. "backtesting_command": (
  223. "rtk freqtrade backtesting --config freqtrade/config-okx-futures.json "
  224. "--userdir freqtrade/user_data --strategy EthFocusedInformativeDry "
  225. "--timeframe 5m --pairs ETH/USDT:USDT --timerange 20230101-"
  226. ),
  227. "notes": [
  228. "The skeleton is for backtest comparison only and does not modify config.",
  229. "The strategy models signal legs with entry tags on one ETH futures pair; it is not a multi-position portfolio allocator.",
  230. "The maker-dependent ETH robust TWAP leg is intentionally excluded.",
  231. ],
  232. }
  233. def build_markdown(payload: dict[str, object]) -> str:
  234. lines = [
  235. "# Freqtrade ETH informative skeleton",
  236. "",
  237. "Purpose: backtest comparison only. No live or dry-run trading command was executed, and no config file was changed.",
  238. "",
  239. "## Generated files",
  240. "",
  241. f"- Strategy: `{payload['strategy']}`",
  242. f"- JSON report: `{payload['json_report']}`",
  243. f"- Markdown report: `{payload['markdown_report']}`",
  244. "",
  245. "## Strategy mapping",
  246. "",
  247. "| Entry tag | Timeframe | Entry | Exit |",
  248. "| --- | --- | --- | --- |",
  249. ]
  250. for leg in payload["legs"]:
  251. lines.append(f"| `{leg['tag']}` | `{leg['timeframe']}` | {leg['entry']} | {leg['exit']} |")
  252. lines.extend(
  253. [
  254. "",
  255. "## Data export",
  256. "",
  257. "Export cached OKX candles into Freqtrade JSON futures files before backtesting:",
  258. "",
  259. ]
  260. )
  261. for command in payload["data_export_commands"]:
  262. lines.append(f"```bash\n{command}\n```")
  263. lines.extend(
  264. [
  265. "",
  266. "## Backtesting",
  267. "",
  268. "Run this only as a backtest comparison against exported data:",
  269. "",
  270. f"```bash\n{payload['backtesting_command']}\n```",
  271. "",
  272. "This uses the existing config path but does not require editing it. The `--pairs ETH/USDT:USDT` argument keeps the run focused on the ETH base pair while BTC is used only as informative data.",
  273. "",
  274. "## Boundaries",
  275. "",
  276. ]
  277. )
  278. for note in payload["notes"]:
  279. lines.append(f"- {note}")
  280. lines.append("")
  281. return "\n".join(lines)
  282. def main() -> int:
  283. generated_at = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
  284. stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
  285. json_path = REPORT_DIR / f"freqtrade-eth-skeleton-{stamp}.json"
  286. md_path = REPORT_DIR / f"freqtrade-eth-skeleton-{stamp}.md"
  287. REPORT_DIR.mkdir(parents=True, exist_ok=True)
  288. STRATEGY_PATH.parent.mkdir(parents=True, exist_ok=True)
  289. STRATEGY_PATH.write_text(STRATEGY_SOURCE, encoding="utf-8")
  290. payload = build_payload(generated_at)
  291. payload["json_report"] = str(json_path.relative_to(ROOT))
  292. payload["markdown_report"] = str(md_path.relative_to(ROOT))
  293. json_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  294. md_path.write_text(build_markdown(payload), encoding="utf-8")
  295. print(STRATEGY_PATH.relative_to(ROOT))
  296. print(json_path.relative_to(ROOT))
  297. print(md_path.relative_to(ROOT))
  298. return 0
  299. if __name__ == "__main__":
  300. raise SystemExit(main())