|
@@ -5,7 +5,7 @@ import json
|
|
|
import os
|
|
import os
|
|
|
import sys
|
|
import sys
|
|
|
import time
|
|
import time
|
|
|
-from dataclasses import asdict, dataclass
|
|
|
|
|
|
|
+from dataclasses import asdict
|
|
|
from datetime import UTC, datetime
|
|
from datetime import UTC, datetime
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
|
|
|
|
@@ -14,6 +14,17 @@ import pandas as pd
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
|
|
|
|
from okx_codex_trader.config import load_config
|
|
from okx_codex_trader.config import load_config
|
|
|
|
|
+from okx_codex_trader.candles import align_candles_by_ts
|
|
|
|
|
+from okx_codex_trader.bb_squeeze_strategy import (
|
|
|
|
|
+ BANDWIDTH_LOOKBACK,
|
|
|
|
|
+ COOLDOWN_BARS,
|
|
|
|
|
+ EMPTY_STATE,
|
|
|
|
|
+ STOP_LOSS_PCT,
|
|
|
|
|
+ TAKE_PROFIT_PCT,
|
|
|
|
|
+ StrategyState,
|
|
|
|
|
+ signal_from_frame,
|
|
|
|
|
+ strategy_name,
|
|
|
|
|
+)
|
|
|
from okx_codex_trader.live_execution import (
|
|
from okx_codex_trader.live_execution import (
|
|
|
TargetPosition,
|
|
TargetPosition,
|
|
|
current_position_from_okx,
|
|
current_position_from_okx,
|
|
@@ -29,32 +40,14 @@ STATE_DIR = ROOT / "var" / "bb-squeeze-executor"
|
|
|
STATE_FILE = "runtime-state.json"
|
|
STATE_FILE = "runtime-state.json"
|
|
|
EVENTS_FILE = "events.jsonl"
|
|
EVENTS_FILE = "events.jsonl"
|
|
|
SYMBOL = "ETH-USDT-SWAP"
|
|
SYMBOL = "ETH-USDT-SWAP"
|
|
|
|
|
+BTC_SYMBOL = "BTC-USDT-SWAP"
|
|
|
BAR = "15m"
|
|
BAR = "15m"
|
|
|
LEVERAGE = 3
|
|
LEVERAGE = 3
|
|
|
|
|
|
|
|
-BAND_LENGTH = 48
|
|
|
|
|
-BANDWIDTH_LOOKBACK = 960
|
|
|
|
|
-BANDWIDTH_QUANTILE = 0.25
|
|
|
|
|
-STOP_LOSS_PCT = 0.01
|
|
|
|
|
-MIDDLE_EXIT_BUFFER_PCT = 0.0005
|
|
|
|
|
-ETH_VOL_CAP = 0.006
|
|
|
|
|
-COOLDOWN_BARS = 24
|
|
|
|
|
LIVE_CANDLE_LIMIT = 1_200
|
|
LIVE_CANDLE_LIMIT = 1_200
|
|
|
RECENT_CANDLE_LIMIT = 20
|
|
RECENT_CANDLE_LIMIT = 20
|
|
|
|
|
|
|
|
|
|
|
|
|
-@dataclass(frozen=True)
|
|
|
|
|
-class StrategyState:
|
|
|
|
|
- last_candle_ts: int | None
|
|
|
|
|
- active_side: str | None
|
|
|
|
|
- entry_price: float | None
|
|
|
|
|
- entry_candle_ts: int | None
|
|
|
|
|
- cooldown_until_ts: int | None
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-EMPTY_STATE = StrategyState(None, None, None, None, None)
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
def now_iso() -> str:
|
|
def now_iso() -> str:
|
|
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
|
|
|
|
|
@@ -69,6 +62,8 @@ def load_state(path: Path) -> StrategyState:
|
|
|
entry_price=payload["entry_price"],
|
|
entry_price=payload["entry_price"],
|
|
|
entry_candle_ts=payload["entry_candle_ts"],
|
|
entry_candle_ts=payload["entry_candle_ts"],
|
|
|
cooldown_until_ts=payload["cooldown_until_ts"],
|
|
cooldown_until_ts=payload["cooldown_until_ts"],
|
|
|
|
|
+ middle_exit_streak=int(payload.get("middle_exit_streak", 0)),
|
|
|
|
|
+ max_favorable_move_pct=payload.get("max_favorable_move_pct"),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -83,113 +78,49 @@ def append_event(state_dir: Path, payload: dict[str, object]) -> None:
|
|
|
handle.write(json.dumps(payload, sort_keys=True) + "\n")
|
|
handle.write(json.dumps(payload, sort_keys=True) + "\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def append_loop_error(state_dir: Path, message: str) -> None:
|
|
|
|
|
+ append_event(
|
|
|
|
|
+ state_dir,
|
|
|
|
|
+ {
|
|
|
|
|
+ "created_at": now_iso(),
|
|
|
|
|
+ "mode": "bb_squeeze_live_executor_error",
|
|
|
|
|
+ "execution_error": message,
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def frame_from_candles(candles: list[Candle]) -> pd.DataFrame:
|
|
def frame_from_candles(candles: list[Candle]) -> pd.DataFrame:
|
|
|
frame = pd.DataFrame([asdict(candle) for candle in candles])
|
|
frame = pd.DataFrame([asdict(candle) for candle in candles])
|
|
|
frame["time"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
|
|
frame["time"] = pd.to_datetime(frame["ts"], unit="ms", utc=True)
|
|
|
return frame.sort_values("ts").drop_duplicates("ts", keep="last").reset_index(drop=True)
|
|
return frame.sort_values("ts").drop_duplicates("ts", keep="last").reset_index(drop=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def aligned_frame_from_candles(eth_candles: list[Candle], btc_candles: list[Candle]) -> pd.DataFrame:
|
|
|
|
|
+ eth_candles, btc_candles = align_candles_by_ts(eth_candles, btc_candles)
|
|
|
|
|
+ eth = frame_from_candles(eth_candles)
|
|
|
|
|
+ btc = frame_from_candles(btc_candles)[["ts", "close"]].rename(columns={"close": "btc_close"})
|
|
|
|
|
+ return eth.merge(btc, on="ts", how="inner").sort_values("ts").reset_index(drop=True)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def load_live_frame(client: OkxClient) -> pd.DataFrame:
|
|
def load_live_frame(client: OkxClient) -> pd.DataFrame:
|
|
|
- return frame_from_candles(client.get_candles(SYMBOL, BAR, LIVE_CANDLE_LIMIT))
|
|
|
|
|
|
|
+ return aligned_frame_from_candles(
|
|
|
|
|
+ client.get_candles(SYMBOL, BAR, LIVE_CANDLE_LIMIT),
|
|
|
|
|
+ client.get_candles(BTC_SYMBOL, BAR, LIVE_CANDLE_LIMIT),
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
def refresh_live_frame(client: OkxClient, frame: pd.DataFrame | None) -> pd.DataFrame:
|
|
def refresh_live_frame(client: OkxClient, frame: pd.DataFrame | None) -> pd.DataFrame:
|
|
|
if frame is None or len(frame) < BANDWIDTH_LOOKBACK + 2:
|
|
if frame is None or len(frame) < BANDWIDTH_LOOKBACK + 2:
|
|
|
return load_live_frame(client)
|
|
return load_live_frame(client)
|
|
|
- recent = frame_from_candles(client.get_recent_candles(SYMBOL, BAR, RECENT_CANDLE_LIMIT))
|
|
|
|
|
|
|
+ recent = aligned_frame_from_candles(
|
|
|
|
|
+ client.get_recent_candles(SYMBOL, BAR, RECENT_CANDLE_LIMIT),
|
|
|
|
|
+ client.get_recent_candles(BTC_SYMBOL, BAR, RECENT_CANDLE_LIMIT),
|
|
|
|
|
+ )
|
|
|
merged = pd.concat([frame, recent], ignore_index=True)
|
|
merged = pd.concat([frame, recent], ignore_index=True)
|
|
|
merged = merged.sort_values("ts").drop_duplicates("ts", keep="last").tail(LIVE_CANDLE_LIMIT)
|
|
merged = merged.sort_values("ts").drop_duplicates("ts", keep="last").tail(LIVE_CANDLE_LIMIT)
|
|
|
return merged.reset_index(drop=True)
|
|
return merged.reset_index(drop=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
|
|
|
|
|
- if len(frame) < BANDWIDTH_LOOKBACK + 2:
|
|
|
|
|
- raise ValueError("not enough candles")
|
|
|
|
|
- close = frame["close"].astype(float)
|
|
|
|
|
- middle = close.rolling(BAND_LENGTH).mean()
|
|
|
|
|
- stdev = close.rolling(BAND_LENGTH).std(ddof=0)
|
|
|
|
|
- upper = middle + (2.0 * stdev)
|
|
|
|
|
- lower = middle - (2.0 * stdev)
|
|
|
|
|
- bandwidth = (upper - lower) / middle
|
|
|
|
|
- threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
|
|
|
|
|
- eth_vol = close.pct_change().rolling(96).std(ddof=0)
|
|
|
|
|
- decision_index = len(frame) - 1
|
|
|
|
|
- row = frame.iloc[decision_index]
|
|
|
|
|
- candle_ts = int(row["ts"])
|
|
|
|
|
- candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
|
|
|
|
|
-
|
|
|
|
|
- indicators = {
|
|
|
|
|
- "eth_close": float(row["close"]),
|
|
|
|
|
- "middle": float(middle.iloc[decision_index]),
|
|
|
|
|
- "upper": float(upper.iloc[decision_index]),
|
|
|
|
|
- "lower": float(lower.iloc[decision_index]),
|
|
|
|
|
- "bandwidth": float(bandwidth.iloc[decision_index]),
|
|
|
|
|
- "bandwidth_threshold": float(threshold.iloc[decision_index]),
|
|
|
|
|
- "eth_vol_96": float(eth_vol.iloc[decision_index]),
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if state.last_candle_ts is not None and candle_ts <= state.last_candle_ts:
|
|
|
|
|
- return state, {
|
|
|
|
|
- "decision_candle_ts": candle_ts,
|
|
|
|
|
- "decision_candle_time": candle_time,
|
|
|
|
|
- "signal": "state_replay",
|
|
|
|
|
- "target_side": state.active_side or "flat",
|
|
|
|
|
- "indicators": indicators,
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- next_state = StrategyState(candle_ts, state.active_side, state.entry_price, state.entry_candle_ts, state.cooldown_until_ts)
|
|
|
|
|
- signal = "hold"
|
|
|
|
|
- target_side = state.active_side or "flat"
|
|
|
|
|
-
|
|
|
|
|
- if state.active_side is not None:
|
|
|
|
|
- entry_price = float(state.entry_price)
|
|
|
|
|
- stop = entry_price * (1.0 - STOP_LOSS_PCT if state.active_side == "long" else 1.0 + STOP_LOSS_PCT)
|
|
|
|
|
- stop_hit = (state.active_side == "long" and float(row["low"]) <= stop) or (state.active_side == "short" and float(row["high"]) >= stop)
|
|
|
|
|
- middle_exit = (state.active_side == "long" and float(row["close"]) < indicators["middle"] * (1.0 - MIDDLE_EXIT_BUFFER_PCT)) or (
|
|
|
|
|
- state.active_side == "short" and float(row["close"]) > indicators["middle"] * (1.0 + MIDDLE_EXIT_BUFFER_PCT)
|
|
|
|
|
- )
|
|
|
|
|
- if stop_hit or middle_exit:
|
|
|
|
|
- signal = "exit_stop" if stop_hit else "exit_middle"
|
|
|
|
|
- target_side = "flat"
|
|
|
|
|
- next_state = StrategyState(
|
|
|
|
|
- candle_ts,
|
|
|
|
|
- None,
|
|
|
|
|
- None,
|
|
|
|
|
- None,
|
|
|
|
|
- candle_ts + (COOLDOWN_BARS * 900_000),
|
|
|
|
|
- )
|
|
|
|
|
- else:
|
|
|
|
|
- cooldown_ok = state.cooldown_until_ts is None or candle_ts >= state.cooldown_until_ts
|
|
|
|
|
- compressed = indicators["bandwidth"] <= indicators["bandwidth_threshold"]
|
|
|
|
|
- vol_ok = indicators["eth_vol_96"] <= ETH_VOL_CAP
|
|
|
|
|
- if cooldown_ok and compressed and vol_ok and float(row["close"]) > indicators["upper"]:
|
|
|
|
|
- signal = "entry_long"
|
|
|
|
|
- target_side = "long"
|
|
|
|
|
- next_state = StrategyState(candle_ts, "long", float(row["close"]), candle_ts, state.cooldown_until_ts)
|
|
|
|
|
- elif cooldown_ok and compressed and vol_ok and float(row["close"]) < indicators["lower"]:
|
|
|
|
|
- signal = "entry_short"
|
|
|
|
|
- target_side = "short"
|
|
|
|
|
- next_state = StrategyState(candle_ts, "short", float(row["close"]), candle_ts, state.cooldown_until_ts)
|
|
|
|
|
-
|
|
|
|
|
- return next_state, {
|
|
|
|
|
- "decision_candle_ts": candle_ts,
|
|
|
|
|
- "decision_candle_time": candle_time,
|
|
|
|
|
- "signal": signal,
|
|
|
|
|
- "target_side": target_side,
|
|
|
|
|
- "indicators": indicators,
|
|
|
|
|
- "params": {
|
|
|
|
|
- "band_length": BAND_LENGTH,
|
|
|
|
|
- "bandwidth_lookback": BANDWIDTH_LOOKBACK,
|
|
|
|
|
- "bandwidth_quantile": BANDWIDTH_QUANTILE,
|
|
|
|
|
- "stop_loss_pct": STOP_LOSS_PCT,
|
|
|
|
|
- "middle_exit_buffer_pct": MIDDLE_EXIT_BUFFER_PCT,
|
|
|
|
|
- "eth_vol_cap": ETH_VOL_CAP,
|
|
|
|
|
- "cooldown_bars": COOLDOWN_BARS,
|
|
|
|
|
- "side_mode": "both",
|
|
|
|
|
- },
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
def account_current_position(client: OkxClient, margin_per_unit_usdt: float) -> tuple[TargetPosition, dict[str, object]]:
|
|
def account_current_position(client: OkxClient, margin_per_unit_usdt: float) -> tuple[TargetPosition, dict[str, object]]:
|
|
|
positions = client.get_positions(SYMBOL)
|
|
positions = client.get_positions(SYMBOL)
|
|
|
metadata = client.get_instrument_meta(SYMBOL)
|
|
metadata = client.get_instrument_meta(SYMBOL)
|
|
@@ -243,6 +174,22 @@ def run_once(
|
|
|
frame = load_live_frame(client)
|
|
frame = load_live_frame(client)
|
|
|
next_state, signal = signal_from_frame(frame, previous_state)
|
|
next_state, signal = signal_from_frame(frame, previous_state)
|
|
|
current, account = account_current_position(client, margin_per_unit_usdt)
|
|
current, account = account_current_position(client, margin_per_unit_usdt)
|
|
|
|
|
+ if previous_state.active_side is not None and current.known and current.side == "flat":
|
|
|
|
|
+ decision_candle_ts = int(signal["decision_candle_ts"])
|
|
|
|
|
+ next_state = StrategyState(
|
|
|
|
|
+ decision_candle_ts,
|
|
|
|
|
+ None,
|
|
|
|
|
+ None,
|
|
|
|
|
+ None,
|
|
|
|
|
+ decision_candle_ts + (COOLDOWN_BARS * 900_000),
|
|
|
|
|
+ 0,
|
|
|
|
|
+ None,
|
|
|
|
|
+ )
|
|
|
|
|
+ signal = {
|
|
|
|
|
+ **signal,
|
|
|
|
|
+ "signal": "external_flat_sync",
|
|
|
|
|
+ "target_side": "flat",
|
|
|
|
|
+ }
|
|
|
target = target_position(signal, current)
|
|
target = target_position(signal, current)
|
|
|
plan = plan_position_delta(current, target)
|
|
plan = plan_position_delta(current, target)
|
|
|
orders = ()
|
|
orders = ()
|
|
@@ -261,12 +208,13 @@ def run_once(
|
|
|
max_total_margin_usdt=max_total_margin_usdt,
|
|
max_total_margin_usdt=max_total_margin_usdt,
|
|
|
client_order_id_prefix=f"bbsq-{signal['decision_candle_ts']}",
|
|
client_order_id_prefix=f"bbsq-{signal['decision_candle_ts']}",
|
|
|
stop_loss_pct=STOP_LOSS_PCT,
|
|
stop_loss_pct=STOP_LOSS_PCT,
|
|
|
|
|
+ take_profit_pct=TAKE_PROFIT_PCT,
|
|
|
)
|
|
)
|
|
|
snapshot = {
|
|
snapshot = {
|
|
|
"created_at": now_iso(),
|
|
"created_at": now_iso(),
|
|
|
"mode": "bb_squeeze_live_executor" if submit_live else "bb_squeeze_dry_run_executor",
|
|
"mode": "bb_squeeze_live_executor" if submit_live else "bb_squeeze_dry_run_executor",
|
|
|
"orders_submitted": 0,
|
|
"orders_submitted": 0,
|
|
|
- "strategy": "bb-squeeze-l48-bw960-q0.25-sl0.01-tpnone-both-none-vc0.006-ddnone-cd24-mxbuf0.0005-mxc1",
|
|
|
|
|
|
|
+ "strategy": strategy_name(),
|
|
|
"previous_state": asdict(previous_state),
|
|
"previous_state": asdict(previous_state),
|
|
|
"next_state": asdict(next_state),
|
|
"next_state": asdict(next_state),
|
|
|
"signal": signal,
|
|
"signal": signal,
|
|
@@ -336,19 +284,23 @@ def main() -> int:
|
|
|
|
|
|
|
|
frame: pd.DataFrame | None = None
|
|
frame: pd.DataFrame | None = None
|
|
|
while True:
|
|
while True:
|
|
|
- frame = refresh_live_frame(OkxClient(), frame)
|
|
|
|
|
- state = load_state(args.state_dir / STATE_FILE)
|
|
|
|
|
- _, loop_signal = signal_from_frame(frame, state)
|
|
|
|
|
- if loop_signal["signal"] != "state_replay":
|
|
|
|
|
- snapshot = run_once(
|
|
|
|
|
- state_dir=args.state_dir,
|
|
|
|
|
- margin_per_unit_usdt=margin_per_unit_usdt,
|
|
|
|
|
- max_new_margin_usdt=max_new_margin_usdt,
|
|
|
|
|
- max_total_margin_usdt=max_total_margin_usdt,
|
|
|
|
|
- submit_live=args.submit_live,
|
|
|
|
|
- frame=frame,
|
|
|
|
|
- )
|
|
|
|
|
- print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ frame = refresh_live_frame(OkxClient(), frame)
|
|
|
|
|
+ state = load_state(args.state_dir / STATE_FILE)
|
|
|
|
|
+ _, loop_signal = signal_from_frame(frame, state)
|
|
|
|
|
+ if loop_signal["signal"] != "state_replay":
|
|
|
|
|
+ snapshot = run_once(
|
|
|
|
|
+ state_dir=args.state_dir,
|
|
|
|
|
+ margin_per_unit_usdt=margin_per_unit_usdt,
|
|
|
|
|
+ max_new_margin_usdt=max_new_margin_usdt,
|
|
|
|
|
+ max_total_margin_usdt=max_total_margin_usdt,
|
|
|
|
|
+ submit_live=args.submit_live,
|
|
|
|
|
+ frame=frame,
|
|
|
|
|
+ )
|
|
|
|
|
+ print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
|
|
|
|
|
+ except ValueError as exc:
|
|
|
|
|
+ append_loop_error(args.state_dir, str(exc))
|
|
|
|
|
+ print(json.dumps({"created_at": now_iso(), "execution_error": str(exc)}, sort_keys=True), flush=True)
|
|
|
time.sleep(args.poll_seconds)
|
|
time.sleep(args.poll_seconds)
|
|
|
return 0
|
|
return 0
|
|
|
|
|
|