run_short_bias_readonly_observer.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. import sys
  5. import time
  6. from dataclasses import asdict, dataclass
  7. from datetime import UTC, datetime
  8. from pathlib import Path
  9. import pandas as pd
  10. sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
  11. from okx_codex_trader.okx_client import OkxClient
  12. from scripts import explore_ultrashort as explore
  13. ROOT = Path(__file__).resolve().parents[1]
  14. STATE_DIR = ROOT / "var" / "short-bias-readonly"
  15. SYMBOLS = ("BTC-USDT-SWAP", "ETH-USDT-SWAP")
  16. SOURCE_BAR = "15m"
  17. HISTORY_LIMIT = 350_400
  18. @dataclass(frozen=True)
  19. class OverlayParams:
  20. allocation: float = 0.05
  21. bar: str = "1h"
  22. btc_trend: int = 1440
  23. btc_lookback: int = 336
  24. vol_lookback: int = 336
  25. btc_max_momentum: float = -0.005
  26. btc_min_drop: float = 0.025
  27. min_btc_vol: float = 0.012
  28. short_symbol: str = "ETH-USDT-SWAP"
  29. PARAMS = OverlayParams()
  30. def now_iso() -> str:
  31. return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
  32. def append_jsonl(path: Path, payload: dict[str, object]) -> None:
  33. path.parent.mkdir(parents=True, exist_ok=True)
  34. with path.open("a", encoding="utf-8") as handle:
  35. handle.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
  36. def load_closes(client: OkxClient) -> pd.DataFrame:
  37. series = {}
  38. for symbol in SYMBOLS:
  39. candles = explore.get_candles_cached(client, symbol, SOURCE_BAR, HISTORY_LIMIT, explore.CANDLE_CACHE_DIR)
  40. frame = pd.DataFrame([asdict(candle) for candle in candles])
  41. frame["ts"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
  42. frame = frame.sort_values("ts").drop_duplicates("ts", keep="last").set_index("ts")
  43. series[symbol] = frame["close"].resample(PARAMS.bar, label="left", closed="left").last().dropna()
  44. return pd.DataFrame(series).dropna()
  45. def annualized_vol(returns: pd.Series) -> pd.Series:
  46. return returns.rolling(PARAMS.vol_lookback).std() * (365 * 24) ** 0.5
  47. def latest_signal(closes: pd.DataFrame) -> dict[str, object]:
  48. btc = closes["BTC-USDT-SWAP"]
  49. eth = closes["ETH-USDT-SWAP"]
  50. btc_trend = btc.rolling(PARAMS.btc_trend).mean()
  51. btc_momentum = btc / btc.shift(PARAMS.btc_lookback) - 1.0
  52. btc_vol = annualized_vol(btc.pct_change())
  53. risk_state = (btc < btc_trend) & (btc_momentum <= PARAMS.btc_max_momentum) & (btc_momentum <= -PARAMS.btc_min_drop) & (btc_vol >= PARAMS.min_btc_vol)
  54. active = bool(risk_state.iloc[-1])
  55. latest = closes.iloc[-1]
  56. return {
  57. "target_side": "short" if active else "flat",
  58. "target_weight": -PARAMS.allocation if active else 0.0,
  59. "entry_signal": active and not bool(risk_state.iloc[-2]),
  60. "exit_signal": (not active) and bool(risk_state.iloc[-2]),
  61. "risk_state": active,
  62. "latest_bar": {
  63. "ts": closes.index[-1].isoformat(),
  64. "btc_close": float(latest["BTC-USDT-SWAP"]),
  65. "eth_close": float(latest["ETH-USDT-SWAP"]),
  66. },
  67. "indicators": {
  68. "btc_sma_1440": float(btc_trend.iloc[-1]),
  69. "btc_momentum_336": float(btc_momentum.iloc[-1]),
  70. "btc_vol_336": float(btc_vol.iloc[-1]),
  71. },
  72. }
  73. def run_once(state_dir: Path) -> dict[str, object]:
  74. state_dir.mkdir(parents=True, exist_ok=True)
  75. closes = load_closes(OkxClient())
  76. signal = latest_signal(closes)
  77. payload = {
  78. "created_at": now_iso(),
  79. "mode": "short_bias_readonly_observer",
  80. "orders_submitted": 0,
  81. "strategy": {
  82. "name": "rotation_plus_5pct_btc_risk_eth_short_overlay",
  83. "symbol": PARAMS.short_symbol,
  84. "bar": PARAMS.bar,
  85. "direction": "short_overlay_readonly",
  86. "params": asdict(PARAMS),
  87. "source_report": "reports/short-bias/overlay-mix-report.md",
  88. "backtest_summary": {
  89. "total_return": 1.512903,
  90. "annualized_return": 0.15593,
  91. "max_drawdown": 0.064999,
  92. "calmar": 2.398957,
  93. "profit_factor": 1.13144,
  94. "win_rate": 0.326781,
  95. "trades": 452,
  96. "return_3y": 0.549897,
  97. "return_1y": 0.180151,
  98. "return_6m": 0.019057,
  99. "return_3m": 0.003631,
  100. },
  101. },
  102. "candles": {
  103. "rows": len(closes),
  104. "first_ts": closes.index[0].isoformat(),
  105. "last_ts": closes.index[-1].isoformat(),
  106. },
  107. "decision": {
  108. "target_side": signal["target_side"],
  109. "target_weight": signal["target_weight"],
  110. "entry_signal": signal["entry_signal"],
  111. "exit_signal": signal["exit_signal"],
  112. "intent": "observe_only_no_order_submission",
  113. },
  114. "signal": signal,
  115. "risk_limits": {
  116. "no_order_submission": True,
  117. "no_cancel_submission": True,
  118. "execution": "read_only_signal_stream",
  119. },
  120. }
  121. (state_dir / "heartbeat.json").write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
  122. append_jsonl(state_dir / "observer-events.jsonl", payload)
  123. return payload
  124. def main() -> int:
  125. parser = argparse.ArgumentParser(description="Run short-bias overlay read-only observer.")
  126. parser.add_argument("--state-dir", type=Path, default=STATE_DIR)
  127. parser.add_argument("--interval-seconds", type=int, default=300)
  128. parser.add_argument("--once", action="store_true")
  129. args = parser.parse_args()
  130. while True:
  131. payload = run_once(args.state_dir)
  132. print(json.dumps(payload, indent=2, sort_keys=True))
  133. if args.once:
  134. return 0
  135. time.sleep(args.interval_seconds)
  136. if __name__ == "__main__":
  137. raise SystemExit(main())