|
@@ -4,6 +4,7 @@ import argparse
|
|
|
import json
|
|
import json
|
|
|
import os
|
|
import os
|
|
|
import sys
|
|
import sys
|
|
|
|
|
+import time
|
|
|
from dataclasses import asdict, dataclass
|
|
from dataclasses import asdict, dataclass
|
|
|
from datetime import UTC, datetime
|
|
from datetime import UTC, datetime
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
@@ -19,6 +20,7 @@ from okx_codex_trader.live_execution import (
|
|
|
plan_position_delta,
|
|
plan_position_delta,
|
|
|
render_market_order_bodies,
|
|
render_market_order_bodies,
|
|
|
)
|
|
)
|
|
|
|
|
+from okx_codex_trader.models import Candle
|
|
|
from okx_codex_trader.okx_client import OkxClient
|
|
from okx_codex_trader.okx_client import OkxClient
|
|
|
|
|
|
|
|
|
|
|
|
@@ -29,7 +31,6 @@ EVENTS_FILE = "events.jsonl"
|
|
|
SYMBOL = "ETH-USDT-SWAP"
|
|
SYMBOL = "ETH-USDT-SWAP"
|
|
|
BAR = "15m"
|
|
BAR = "15m"
|
|
|
LEVERAGE = 3
|
|
LEVERAGE = 3
|
|
|
-CANDLES_PATH = ROOT / "data" / "okx-candles" / SYMBOL / f"{BAR}.csv"
|
|
|
|
|
|
|
|
|
|
BAND_LENGTH = 48
|
|
BAND_LENGTH = 48
|
|
|
BANDWIDTH_LOOKBACK = 960
|
|
BANDWIDTH_LOOKBACK = 960
|
|
@@ -37,6 +38,8 @@ BANDWIDTH_QUANTILE = 0.25
|
|
|
STOP_LOSS_PCT = 0.01
|
|
STOP_LOSS_PCT = 0.01
|
|
|
ETH_VOL_CAP = 0.006
|
|
ETH_VOL_CAP = 0.006
|
|
|
COOLDOWN_BARS = 24
|
|
COOLDOWN_BARS = 24
|
|
|
|
|
+LIVE_CANDLE_LIMIT = 1_200
|
|
|
|
|
+RECENT_CANDLE_LIMIT = 20
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
@dataclass(frozen=True)
|
|
@@ -79,14 +82,27 @@ 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 load_frame() -> pd.DataFrame:
|
|
|
|
|
- frame = pd.read_csv(CANDLES_PATH)
|
|
|
|
|
|
|
+def frame_from_candles(candles: list[Candle]) -> pd.DataFrame:
|
|
|
|
|
+ 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 load_live_frame(client: OkxClient) -> pd.DataFrame:
|
|
|
|
|
+ return frame_from_candles(client.get_candles(SYMBOL, BAR, LIVE_CANDLE_LIMIT))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def refresh_live_frame(client: OkxClient, frame: pd.DataFrame | None) -> pd.DataFrame:
|
|
|
|
|
+ if frame is None or len(frame) < BANDWIDTH_LOOKBACK + 2:
|
|
|
|
|
+ return load_live_frame(client)
|
|
|
|
|
+ recent = frame_from_candles(client.get_recent_candles(SYMBOL, BAR, RECENT_CANDLE_LIMIT))
|
|
|
|
|
+ merged = pd.concat([frame, recent], ignore_index=True)
|
|
|
|
|
+ merged = merged.sort_values("ts").drop_duplicates("ts", keep="last").tail(LIVE_CANDLE_LIMIT)
|
|
|
|
|
+ return merged.reset_index(drop=True)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
|
|
def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[StrategyState, dict[str, object]]:
|
|
|
- if len(frame) < BANDWIDTH_LOOKBACK + 3:
|
|
|
|
|
|
|
+ if len(frame) < BANDWIDTH_LOOKBACK + 2:
|
|
|
raise ValueError("not enough candles")
|
|
raise ValueError("not enough candles")
|
|
|
close = frame["close"].astype(float)
|
|
close = frame["close"].astype(float)
|
|
|
middle = close.rolling(BAND_LENGTH).mean()
|
|
middle = close.rolling(BAND_LENGTH).mean()
|
|
@@ -96,7 +112,7 @@ def signal_from_frame(frame: pd.DataFrame, state: StrategyState) -> tuple[Strate
|
|
|
bandwidth = (upper - lower) / middle
|
|
bandwidth = (upper - lower) / middle
|
|
|
threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
|
|
threshold = bandwidth.rolling(BANDWIDTH_LOOKBACK).quantile(BANDWIDTH_QUANTILE)
|
|
|
eth_vol = close.pct_change().rolling(96).std(ddof=0)
|
|
eth_vol = close.pct_change().rolling(96).std(ddof=0)
|
|
|
- decision_index = len(frame) - 2
|
|
|
|
|
|
|
+ decision_index = len(frame) - 1
|
|
|
row = frame.iloc[decision_index]
|
|
row = frame.iloc[decision_index]
|
|
|
candle_ts = int(row["ts"])
|
|
candle_ts = int(row["ts"])
|
|
|
candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
|
|
candle_time = pd.Timestamp(row["time"]).isoformat().replace("+00:00", "Z")
|
|
@@ -215,12 +231,15 @@ def run_once(
|
|
|
max_new_margin_usdt: float,
|
|
max_new_margin_usdt: float,
|
|
|
max_total_margin_usdt: float,
|
|
max_total_margin_usdt: float,
|
|
|
submit_live: bool,
|
|
submit_live: bool,
|
|
|
|
|
+ frame: pd.DataFrame | None = None,
|
|
|
) -> dict[str, object]:
|
|
) -> dict[str, object]:
|
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
state_path = state_dir / STATE_FILE
|
|
state_path = state_dir / STATE_FILE
|
|
|
previous_state = load_state(state_path)
|
|
previous_state = load_state(state_path)
|
|
|
- next_state, signal = signal_from_frame(load_frame(), previous_state)
|
|
|
|
|
client = OkxClient(load_config())
|
|
client = OkxClient(load_config())
|
|
|
|
|
+ if frame is None:
|
|
|
|
|
+ frame = load_live_frame(client)
|
|
|
|
|
+ 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)
|
|
|
target = target_position(signal, current)
|
|
target = target_position(signal, current)
|
|
|
plan = plan_position_delta(current, target)
|
|
plan = plan_position_delta(current, target)
|
|
@@ -239,6 +258,7 @@ def run_once(
|
|
|
max_new_margin_usdt=max_new_margin_usdt,
|
|
max_new_margin_usdt=max_new_margin_usdt,
|
|
|
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,
|
|
|
)
|
|
)
|
|
|
snapshot = {
|
|
snapshot = {
|
|
|
"created_at": now_iso(),
|
|
"created_at": now_iso(),
|
|
@@ -293,17 +313,41 @@ def main() -> int:
|
|
|
parser.add_argument("--max-total-margin-usdt", type=float)
|
|
parser.add_argument("--max-total-margin-usdt", type=float)
|
|
|
parser.add_argument("--submit-live", action="store_true")
|
|
parser.add_argument("--submit-live", action="store_true")
|
|
|
parser.add_argument("--confirm-live", action="store_true")
|
|
parser.add_argument("--confirm-live", action="store_true")
|
|
|
|
|
+ parser.add_argument("--loop", action="store_true")
|
|
|
|
|
+ parser.add_argument("--poll-seconds", type=float, default=10.0)
|
|
|
args = parser.parse_args()
|
|
args = parser.parse_args()
|
|
|
if args.submit_live != args.confirm_live:
|
|
if args.submit_live != args.confirm_live:
|
|
|
raise ValueError("--submit-live and --confirm-live must be used together")
|
|
raise ValueError("--submit-live and --confirm-live must be used together")
|
|
|
- snapshot = run_once(
|
|
|
|
|
- state_dir=args.state_dir,
|
|
|
|
|
- margin_per_unit_usdt=risk_arg(args.margin_per_unit_usdt, "ETH_NEXTGEN_MARGIN_PER_UNIT_USDT"),
|
|
|
|
|
- max_new_margin_usdt=risk_arg(args.max_new_margin_usdt, "ETH_NEXTGEN_MAX_NEW_MARGIN_USDT"),
|
|
|
|
|
- max_total_margin_usdt=risk_arg(args.max_total_margin_usdt, "ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT"),
|
|
|
|
|
- submit_live=args.submit_live,
|
|
|
|
|
- )
|
|
|
|
|
- print(json.dumps(snapshot, indent=2, sort_keys=True))
|
|
|
|
|
|
|
+ margin_per_unit_usdt = risk_arg(args.margin_per_unit_usdt, "ETH_NEXTGEN_MARGIN_PER_UNIT_USDT")
|
|
|
|
|
+ max_new_margin_usdt = risk_arg(args.max_new_margin_usdt, "ETH_NEXTGEN_MAX_NEW_MARGIN_USDT")
|
|
|
|
|
+ max_total_margin_usdt = risk_arg(args.max_total_margin_usdt, "ETH_NEXTGEN_MAX_TOTAL_MARGIN_USDT")
|
|
|
|
|
+ if not args.loop:
|
|
|
|
|
+ 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,
|
|
|
|
|
+ )
|
|
|
|
|
+ print(json.dumps(snapshot, indent=2, sort_keys=True), flush=True)
|
|
|
|
|
+ return 0
|
|
|
|
|
+
|
|
|
|
|
+ frame: pd.DataFrame | None = None
|
|
|
|
|
+ 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)
|
|
|
|
|
+ time.sleep(args.poll_seconds)
|
|
|
return 0
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|