build_eth_signal_intent_readonly.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. from __future__ import annotations
  2. import argparse
  3. import csv
  4. import json
  5. import math
  6. import sys
  7. from dataclasses import dataclass
  8. from datetime import UTC, datetime, timedelta
  9. from decimal import Decimal, ROUND_DOWN
  10. from pathlib import Path
  11. ROOT = Path(__file__).resolve().parents[1]
  12. sys.path.insert(0, str(ROOT))
  13. from okx_codex_trader.models import Candle
  14. from okx_codex_trader.okx_client import OkxClient
  15. SYMBOL = "ETH-USDT-SWAP"
  16. BAR = "15m"
  17. BAR_MS = 900_000
  18. LEVERAGE = 3
  19. TREND_SMA = 60
  20. RSI_LENGTH = 2
  21. RSI_THRESHOLD = 3.0
  22. EXIT_RSI = 50.0
  23. STOP_LOSS_PCT = 0.012
  24. MAX_HOLD_BARS = 48
  25. ENTRY_OFFSETS = (0.003, 0.006, 0.009)
  26. ENTRY_VALID_BARS = 4
  27. REPORT_DIR = ROOT / "reports" / "eth-exploration"
  28. JSON_REPORT = REPORT_DIR / "eth-signal-intent-readonly.json"
  29. MARKDOWN_REPORT = REPORT_DIR / "eth-signal-intent-readonly.md"
  30. LOCAL_CANDLES = ROOT / "data" / "okx-candles" / SYMBOL / f"{BAR}.csv"
  31. @dataclass(frozen=True)
  32. class Instrument:
  33. ct_val: Decimal
  34. lot_sz: Decimal
  35. min_sz: Decimal
  36. tick_sz: Decimal
  37. def utc_text(ts: int) -> str:
  38. return datetime.fromtimestamp(ts / 1000, UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
  39. def iso_text(ts: int) -> str:
  40. return datetime.fromtimestamp(ts / 1000, UTC).isoformat().replace("+00:00", "Z")
  41. def decimal_text(value: Decimal) -> str:
  42. return format(value.normalize(), "f")
  43. def floor_to_step(value: Decimal, step: Decimal) -> Decimal:
  44. return (value / step).to_integral_value(rounding=ROUND_DOWN) * step
  45. def sma(values: list[float], length: int, index: int) -> float:
  46. if index + 1 < length:
  47. return float("nan")
  48. return sum(values[index + 1 - length : index + 1]) / length
  49. def compute_rsi(values: list[float], length: int) -> list[float]:
  50. rsi = [float("nan")] * len(values)
  51. if len(values) <= length:
  52. return rsi
  53. gains: list[float] = [0.0]
  54. losses: list[float] = [0.0]
  55. for prev, current in zip(values, values[1:]):
  56. delta = current - prev
  57. gains.append(max(delta, 0.0))
  58. losses.append(max(-delta, 0.0))
  59. average_gain = sum(gains[1 : length + 1]) / length
  60. average_loss = sum(losses[1 : length + 1]) / length
  61. for index in range(length, len(values)):
  62. if index > length:
  63. average_gain = ((average_gain * (length - 1)) + gains[index]) / length
  64. average_loss = ((average_loss * (length - 1)) + losses[index]) / length
  65. if average_loss == 0.0:
  66. rsi[index] = 100.0 if average_gain > 0.0 else 50.0
  67. else:
  68. relative_strength = average_gain / average_loss
  69. rsi[index] = 100.0 - (100.0 / (1.0 + relative_strength))
  70. return rsi
  71. def load_local_candles(path: Path, symbol: str) -> list[Candle]:
  72. candles: list[Candle] = []
  73. with path.open("r", encoding="utf-8", newline="") as handle:
  74. for row in csv.DictReader(handle):
  75. candles.append(
  76. Candle(
  77. symbol=symbol,
  78. ts=int(row["ts"]),
  79. open=float(row["open"]),
  80. high=float(row["high"]),
  81. low=float(row["low"]),
  82. close=float(row["close"]),
  83. volume=float(row["volume"]),
  84. )
  85. )
  86. return sorted(candles, key=lambda candle: candle.ts)
  87. def load_okx_candles(symbol: str, bar: str, limit: int) -> list[Candle]:
  88. return OkxClient().get_candles(symbol, bar, limit)
  89. def load_okx_instrument(symbol: str) -> Instrument:
  90. data = OkxClient()._request("GET", "/api/v5/public/instruments", params={"instType": "SWAP", "instId": symbol})
  91. if not data or not isinstance(data[0], dict):
  92. raise ValueError("okx instrument payload is invalid")
  93. row = data[0]
  94. return Instrument(
  95. ct_val=Decimal(str(row["ctVal"])),
  96. lot_sz=Decimal(str(row["lotSz"])),
  97. min_sz=Decimal(str(row["minSz"])),
  98. tick_sz=Decimal(str(row["tickSz"])),
  99. )
  100. def build_payload(args: argparse.Namespace) -> dict[str, object]:
  101. candles = load_okx_candles(args.symbol, args.bar, args.limit) if args.source == "okx" else load_local_candles(args.local_candles, args.symbol)
  102. candles = candles[-args.limit :]
  103. if len(candles) < TREND_SMA + 2:
  104. raise ValueError("not enough candles")
  105. latest_confirmed = candles[-1]
  106. decision_index = len(candles) - 2
  107. decision = candles[decision_index]
  108. closes = [candle.close for candle in candles[: decision_index + 1]]
  109. rsi_values = compute_rsi(closes, RSI_LENGTH)
  110. trend = sma(closes, TREND_SMA, len(closes) - 1)
  111. rsi2 = rsi_values[-1]
  112. if math.isnan(trend) or math.isnan(rsi2):
  113. raise ValueError("not enough indicator history")
  114. signal = decision.close > trend and rsi2 <= RSI_THRESHOLD
  115. instrument = load_okx_instrument(args.symbol) if args.instrument == "okx" else Instrument(
  116. ct_val=Decimal(args.ct_val),
  117. lot_sz=Decimal(args.lot_sz),
  118. min_sz=Decimal(args.min_sz),
  119. tick_sz=Decimal(args.tick_sz),
  120. )
  121. planned_margin = Decimal(args.planned_margin_usdt)
  122. slice_margin = planned_margin / Decimal(len(ENTRY_OFFSETS))
  123. created_at = datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
  124. first_valid_ts = decision.ts + BAR_MS
  125. cancel_after_ts = decision.ts + (ENTRY_VALID_BARS + 1) * BAR_MS
  126. expires_at = datetime.fromtimestamp(cancel_after_ts / 1000, UTC).isoformat().replace("+00:00", "Z")
  127. orders: list[dict[str, object]] = []
  128. if signal:
  129. for index, offset in enumerate(ENTRY_OFFSETS, start=1):
  130. raw_px = Decimal(str(decision.close)) * (Decimal("1") - Decimal(str(offset)))
  131. px = floor_to_step(raw_px, instrument.tick_sz)
  132. notional = slice_margin * Decimal(LEVERAGE)
  133. sz = floor_to_step(notional / (px * instrument.ct_val), instrument.lot_sz)
  134. if sz < instrument.min_sz:
  135. raise ValueError("planned margin is below instrument minimum size")
  136. orders.append(
  137. {
  138. "level": index,
  139. "offset": offset,
  140. "payload": {
  141. "instId": args.symbol,
  142. "tdMode": "isolated",
  143. "side": "buy",
  144. "posSide": "long",
  145. "ordType": "post_only",
  146. "px": decimal_text(px),
  147. "sz": decimal_text(sz),
  148. "clOrdId": f"ethrtwap15m-{decision.ts}-l{index}",
  149. },
  150. "estimated_slice_margin_usdt": decimal_text(slice_margin),
  151. "estimated_notional_usdt": decimal_text(notional),
  152. "valid_from_candle_ts": first_valid_ts,
  153. "valid_from": iso_text(first_valid_ts),
  154. "expires_after_candle_ts": decision.ts + ENTRY_VALID_BARS * BAR_MS,
  155. "cancel_at_ts": cancel_after_ts,
  156. "cancel_at": expires_at,
  157. "state_fields": {
  158. "client_order_id": f"ethrtwap15m-{decision.ts}-l{index}",
  159. "okx_order_id": None,
  160. "level": index,
  161. "price": decimal_text(px),
  162. "requested_size": decimal_text(sz),
  163. "filled_size": "0",
  164. "state": "intent_only",
  165. "created_at": created_at,
  166. "expires_at": expires_at,
  167. },
  168. }
  169. )
  170. return {
  171. "mode": "readonly_order_intent",
  172. "submitted_orders": 0,
  173. "source": {"candles": args.source, "instrument": args.instrument},
  174. "strategy": {
  175. "symbol": args.symbol,
  176. "bar": args.bar,
  177. "direction": "long",
  178. "leverage": LEVERAGE,
  179. "trend_sma": TREND_SMA,
  180. "rsi_length": RSI_LENGTH,
  181. "entry_signal": "decision_close > sma60 and rsi2 <= 3.0",
  182. "entry_offsets": list(ENTRY_OFFSETS),
  183. "entry_valid_bars": ENTRY_VALID_BARS,
  184. "exit_rsi": EXIT_RSI,
  185. "max_hold_bars": MAX_HOLD_BARS,
  186. "stop_loss_pct": STOP_LOSS_PCT,
  187. },
  188. "clock": {
  189. "latest_confirmed_candle_ts": latest_confirmed.ts,
  190. "latest_confirmed_candle_time": utc_text(latest_confirmed.ts),
  191. "decision_candle_ts": decision.ts,
  192. "decision_candle_time": utc_text(decision.ts),
  193. "indicator_rule": "confirmed 15m candles strictly before the latest confirmed candle",
  194. },
  195. "decision": {
  196. "close": decision.close,
  197. "sma60": trend,
  198. "rsi2": rsi2,
  199. "signal": signal,
  200. "reason": "triggered" if signal else "no signal",
  201. },
  202. "instrument": {
  203. "ctVal": decimal_text(instrument.ct_val),
  204. "lotSz": decimal_text(instrument.lot_sz),
  205. "minSz": decimal_text(instrument.min_sz),
  206. "tickSz": decimal_text(instrument.tick_sz),
  207. },
  208. "risk": {
  209. "planned_margin_usdt": decimal_text(planned_margin),
  210. "slice_margin_usdt": decimal_text(slice_margin),
  211. "stop_price_if_all_levels_fill": decimal_text(
  212. floor_to_step(
  213. sum(Decimal(order["payload"]["px"]) for order in orders) / Decimal(len(orders)) * (Decimal("1") - Decimal(str(STOP_LOSS_PCT))),
  214. instrument.tick_sz,
  215. )
  216. )
  217. if orders
  218. else None,
  219. },
  220. "order_intents": orders,
  221. "state_preview": {
  222. "strategy_id": "eth_robust_twap_15m_readonly",
  223. "state": "entry_orders_open" if orders else "idle",
  224. "last_confirmed_candle_ts": latest_confirmed.ts,
  225. "last_signal_candle_ts": decision.ts if signal else None,
  226. "orders": [order["state_fields"] for order in orders],
  227. "position": {
  228. "filled_contracts": "0",
  229. "filled_base_qty": "0",
  230. "filled_notional_usdt": "0",
  231. "avg_entry_price": None,
  232. "margin_used": "0",
  233. "stop_price": None,
  234. "first_fill_ts": None,
  235. "entry_candle_ts": None,
  236. },
  237. "updated_at": created_at,
  238. },
  239. }
  240. def write_reports(payload: dict[str, object]) -> None:
  241. REPORT_DIR.mkdir(parents=True, exist_ok=True)
  242. JSON_REPORT.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  243. decision = payload["decision"]
  244. clock = payload["clock"]
  245. orders = payload["order_intents"]
  246. lines = [
  247. "# ETH signal intent readonly dry-run",
  248. "",
  249. "No orders were submitted. This report is an order-intent draft only.",
  250. "",
  251. "## Signal",
  252. "",
  253. f"- Latest confirmed candle: `{clock['latest_confirmed_candle_time']}` (`{clock['latest_confirmed_candle_ts']}`)",
  254. f"- Decision candle: `{clock['decision_candle_time']}` (`{clock['decision_candle_ts']}`)",
  255. f"- Decision close: `{decision['close']}`",
  256. f"- SMA60: `{decision['sma60']}`",
  257. f"- RSI2: `{decision['rsi2']}`",
  258. f"- Signal: `{decision['signal']}`",
  259. "",
  260. "## Order Intents",
  261. "",
  262. ]
  263. if orders:
  264. lines.extend(
  265. [
  266. "| Level | px | sz | clOrdId | cancel_at |",
  267. "| --- | --- | --- | --- | --- |",
  268. ]
  269. )
  270. for order in orders:
  271. body = order["payload"]
  272. lines.append(f"| {order['level']} | `{body['px']}` | `{body['sz']}` | `{body['clOrdId']}` | `{order['cancel_at']}` |")
  273. else:
  274. lines.append("No order intent was produced because the signal condition did not trigger.")
  275. lines.extend(
  276. [
  277. "",
  278. "## State Preview",
  279. "",
  280. "```json",
  281. json.dumps(payload["state_preview"], indent=2, sort_keys=True),
  282. "```",
  283. "",
  284. "## Files",
  285. "",
  286. f"- JSON: `{JSON_REPORT.relative_to(ROOT)}`",
  287. f"- Markdown: `{MARKDOWN_REPORT.relative_to(ROOT)}`",
  288. ]
  289. )
  290. MARKDOWN_REPORT.write_text("\n".join(lines) + "\n", encoding="utf-8")
  291. def main() -> int:
  292. parser = argparse.ArgumentParser(description="Build read-only ETH Robust TWAP signal/order-intent payloads.")
  293. parser.add_argument("--source", choices=("local", "okx"), default="local")
  294. parser.add_argument("--instrument", choices=("static", "okx"), default="static")
  295. parser.add_argument("--symbol", default=SYMBOL)
  296. parser.add_argument("--bar", default=BAR)
  297. parser.add_argument("--limit", type=int, default=500)
  298. parser.add_argument("--local-candles", type=Path, default=LOCAL_CANDLES)
  299. parser.add_argument("--planned-margin-usdt", default="30")
  300. parser.add_argument("--ct-val", default="0.1")
  301. parser.add_argument("--lot-sz", default="0.01")
  302. parser.add_argument("--min-sz", default="0.01")
  303. parser.add_argument("--tick-sz", default="0.01")
  304. parser.add_argument("--no-write", action="store_true")
  305. args = parser.parse_args()
  306. payload = build_payload(args)
  307. if not args.no_write:
  308. write_reports(payload)
  309. print(json.dumps(payload, indent=2, sort_keys=True))
  310. return 0
  311. if __name__ == "__main__":
  312. raise SystemExit(main())