diff --git a/README.md b/README.md index 2251ea1..ed58724 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ Open **http://127.0.0.1:8090**. strategy, press **Start**. The bot trades live Binance prices without sending orders. **Reset wallet** starts a fresh session with a new balance; every session keeps its own database in `user_data/paper/`, so old results aren't lost. + **Resume** restarts the last session after a reboot, crash or Stop, with the + same wallet, open trades and pair list. A 4-week paper test survives restarts. While it runs, **Open FreqUI** (http://127.0.0.1:8080, log in with the username/password from `config.private.json`) shows charts, per-trade detail and more. - **Replay:** pick strategy, balance and date range. It simulates that period in diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0d01ed7..74becad 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -231,6 +231,10 @@ The result notes things the replay can't reproduce, so check those first: - drawdown halts, which need a manual resume live but resume automatically in backtests - strategy code or parameters changed during the session - paper trades newer than the latest closed candle +- time the bot was offline (a reboot, sleep or crash): any stretch of 10+ + minutes without log output. The replay keeps trading through these gaps, so + keep downtime short. After a restart, press **Resume** rather than Reset: + Resume continues the same session. **If it fails.** Don't loosen the targets. Find out why the two diverged. If the backtest assumptions are wrong (for example, real fills are consistently diff --git a/panel/app.py b/panel/app.py index 709a99f..2904fff 100644 --- a/panel/app.py +++ b/panel/app.py @@ -100,6 +100,14 @@ def paper_reset(req: PaperReset): raise HTTPException(409, str(e)) +@app.post("/api/paper/resume") +def paper_resume(): + try: + return bot.resume_session() + except PaperBotError as e: + raise HTTPException(409, str(e)) + + @app.get("/api/mode") def mode(): return {"monitor": MONITOR_MODE, "version": __version__} diff --git a/panel/bot.py b/panel/bot.py index 5f232ca..b78a5e1 100644 --- a/panel/bot.py +++ b/panel/bot.py @@ -29,7 +29,7 @@ from panel.common import FREQTRADE, ROOT, USERDIR, config_args, load_json, merged_config from ridgeline import audit -from ridgeline.reconcile import strategy_fingerprint +from ridgeline.reconcile import session_info, strategy_fingerprint from ridgeline.risk import RiskLimits, build_state, limits_summary, load_halt, set_halt MONITOR_MODE = os.environ.get("PANEL_MODE", "").lower() == "monitor" @@ -87,11 +87,9 @@ def _our_process(self) -> Optional[psutil.Process]: # --- actions --- - def start(self, balance: float, strategy: str) -> dict: + def _preflight(self) -> None: if MONITOR_MODE: raise PaperBotError("Process control is disabled in monitor mode (Docker manages the bot).") - if balance <= 0: - raise PaperBotError("Starting balance must be positive.") if self._our_process(): raise PaperBotError("A paper session is already running. Stop or reset it first.") if _port_open(self.base_url): @@ -100,30 +98,84 @@ def start(self, balance: float, strategy: str) -> dict: "started by hand). Stop it first - the panel will not kill processes it didn't start." ) + def start(self, balance: float, strategy: str) -> dict: + if balance <= 0: + raise PaperBotError("Starting balance must be positive.") + self._preflight() + session_id = datetime.now().strftime("%Y%m%d-%H%M%S") + cfg = merged_config() + pairs = cfg.get("exchange", {}).get("pair_whitelist", []) session_cfg = PAPER_DIR / f"{session_id}.config.json" session_cfg.write_text(json.dumps({ "dry_run": True, "dry_run_wallet": balance, "db_url": f"sqlite:///user_data/paper/{session_id}.sqlite", "bot_name": f"paper-{session_id}", - "exchange": {"key": "", "secret": ""}, + # Pin the pair list: config.json's changes monthly, and a session + # resumed after a restart must keep trading what it started with. + "exchange": {"key": "", "secret": "", "pair_whitelist": pairs}, }, indent=2)) args = [*config_args(), "-c", str(session_cfg)] self._verify_dry_run(args) # What this session runs, for reconciling it with a replay later: # config.json's pair list changes monthly, and code can change mid-session. - cfg = merged_config() (PAPER_DIR / f"{session_id}.meta.json").write_text(json.dumps({ "session_id": session_id, "strategy": strategy, "balance": balance, - "pairs": cfg.get("exchange", {}).get("pair_whitelist", []), + "pairs": pairs, "max_open_trades": cfg.get("max_open_trades"), "started": datetime.now(timezone.utc).isoformat(timespec="seconds"), "code": strategy_fingerprint(strategy), }, indent=2)) + state = { + "pid": self._spawn(session_id, args, strategy), + "session_id": session_id, + "strategy": strategy, + "balance": balance, + "started": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + self._save_state(state) + audit.append("paper_session_started", "panel", + {"session_id": session_id, "strategy": strategy, "balance": balance}) + return state + + def resume_session(self) -> dict: + """Restart the last session's bot on the same database, after a reboot, + a crash or a Stop. Open trades, the wallet and the trade history carry + on, so a weeks-long paper test isn't lost to a restart.""" + self._preflight() + state = self._state() + session_id = state.get("session_id") + if not session_id: + raise PaperBotError("There's no previous paper session to resume.") + session_cfg = PAPER_DIR / f"{session_id}.config.json" + db = PAPER_DIR / f"{session_id}.sqlite" + if not session_cfg.exists() or not db.exists(): + raise PaperBotError(f"Session {session_id}'s files are missing. Start a new session instead.") + try: + info = session_info(db) + except ValueError as e: + raise PaperBotError(f"Can't resume session {session_id}: {e}") from e + cfg = load_json(session_cfg, {}) or {} + if not cfg.get("exchange", {}).get("pair_whitelist"): + # Sessions from before pairs were pinned: keep what they traded, + # not whatever config.json lists today. + cfg.setdefault("exchange", {}).update(key="", secret="", pair_whitelist=info.pairs) + session_cfg.write_text(json.dumps(cfg, indent=2)) + args = [*config_args(), "-c", str(session_cfg)] + self._verify_dry_run(args) + strategy = state.get("strategy") or info.strategy + state.update(pid=self._spawn(session_id, args, strategy), strategy=strategy, + resumed=[*state.get("resumed", []), datetime.now(timezone.utc).isoformat(timespec="seconds")]) + state.pop("stopped", None) + self._save_state(state) + audit.append("paper_session_resumed", "panel", {"session_id": session_id, "strategy": strategy}) + return state + + def _spawn(self, session_id: str, args: list[str], strategy: str) -> int: log_path = PAPER_DIR / f"{session_id}.log" cmd = [*FREQTRADE, "trade", *args, "--strategy", strategy, "--logfile", str(log_path)] popen_kw = dict(cwd=ROOT, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, @@ -138,17 +190,7 @@ def start(self, balance: float, strategy: str) -> dict: proc = subprocess.Popen(cmd, creationflags=flags, **popen_kw) else: proc = subprocess.Popen(cmd, start_new_session=True, **popen_kw) - state = { - "pid": proc.pid, - "session_id": session_id, - "strategy": strategy, - "balance": balance, - "started": datetime.now(timezone.utc).isoformat(timespec="seconds"), - } - self._save_state(state) - audit.append("paper_session_started", "panel", - {"session_id": session_id, "strategy": strategy, "balance": balance}) - return state + return proc.pid def _verify_dry_run(self, args: list[str]) -> None: out = subprocess.run([*FREQTRADE, "show-config", *args], cwd=ROOT, @@ -289,6 +331,8 @@ def status(self) -> dict: "api_up": False, "session": state if ours else None, "last_session": state if not ours and state else None, + "can_resume": (not ours and not listening and not MONITOR_MODE and bool(state.get("session_id")) + and (PAPER_DIR / f"{state.get('session_id')}.sqlite").exists()), "freqtrade_ui": self.base_url, } if not listening: diff --git a/panel/static/app.js b/panel/static/app.js index 6a673c9..9536f50 100644 --- a/panel/static/app.js +++ b/panel/static/app.js @@ -130,6 +130,7 @@ async function refreshPaper() { $("#paper-start").disabled = paperBusy || st.managed || st.external || monitorMode; $("#paper-stop").disabled = paperBusy || !st.managed || monitorMode; + $("#paper-resume").disabled = paperBusy || !st.can_resume; $("#paper-reset").disabled = paperBusy || st.external || monitorMode; ["#risk-pause", "#risk-resume", "#risk-flatten"].forEach((id) => ($(id).disabled = paperBusy || !st.api_up)); if (st.api_up) refreshRisk(); else $("#risk-meters").innerHTML = `
Available while a bot is running.
`; @@ -196,6 +197,7 @@ $("#paper-form").addEventListener("submit", (e) => { paperAction(() => api("/api/paper/start", { method: "POST", body: { balance: +$("#paper-balance").value, strategy: $("#paper-strategy").value } })); }); $("#paper-stop").addEventListener("click", () => paperAction(() => api("/api/paper/stop", { method: "POST" }))); +$("#paper-resume").addEventListener("click", () => paperAction(() => api("/api/paper/resume", { method: "POST" }))); $("#paper-reset").addEventListener("click", () => { const bal = +$("#paper-balance").value; if (!confirm(`Start a fresh paper session with ${bal} USDT? The current session's trades are kept in its own archive file.`)) return; diff --git a/panel/static/index.html b/panel/static/index.html index a99af99..76519b3 100644 --- a/panel/static/index.html +++ b/panel/static/index.html @@ -55,6 +55,7 @@