Initial commit: multi-symbol bot with backtest engine and RSI trend strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Paper trading runner with live dashboard.
|
||||
|
||||
- Immediately runs a full signal scan (no waiting for bar boundary)
|
||||
- Opens paper positions where signals are found
|
||||
- Refreshes the dashboard every 60 seconds showing live P&L
|
||||
- Press Ctrl+C to stop
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
# ── imports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
from bot import config, broker, risk
|
||||
from bot.signals import check
|
||||
from bot.loop import _execute, _has_open
|
||||
from bot.paper_client import _load_state, STATE_PATH
|
||||
|
||||
import logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)-8s %(message)s",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
for noisy in ("httpx", "telegram", "asyncio", "yfinance", "peewee"):
|
||||
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger("paper")
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _pip_size(symbol: str) -> float:
|
||||
return 0.01 if symbol.endswith("JPY") or symbol.endswith("CHF") and "JPY" in symbol else 0.0001
|
||||
|
||||
|
||||
async def _unrealised_pnl(pos: dict) -> float:
|
||||
"""Current unrealised P&L in pips for an open paper position."""
|
||||
try:
|
||||
from bot.paper_client import get_tick
|
||||
tick = await get_tick(pos["symbol"])
|
||||
if tick is None:
|
||||
return 0.0
|
||||
mid = (tick["bid"] + tick["ask"]) / 2
|
||||
ps = _pip_size(pos["symbol"])
|
||||
direction = pos["direction"] # 0=BUY, 1=SELL
|
||||
raw = mid - pos["entry"] if direction == 0 else pos["entry"] - mid
|
||||
return raw / ps
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _cls():
|
||||
os.system("clear" if os.name != "nt" else "cls")
|
||||
|
||||
|
||||
def _bar(pct: float, width: int = 20) -> str:
|
||||
filled = int(abs(pct) / max(abs(pct), 1e-9) * width)
|
||||
filled = min(filled, width)
|
||||
return ("█" * filled).ljust(width)
|
||||
|
||||
|
||||
# ── dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _dashboard():
|
||||
state = _load_state()
|
||||
positions = state.get("positions", [])
|
||||
history = state.get("history", [])
|
||||
balance = state.get("balance", config.PAPER_INITIAL_BALANCE)
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
initial = config.PAPER_INITIAL_BALANCE
|
||||
total_ret = (balance / initial - 1) * 100
|
||||
|
||||
_cls()
|
||||
print("╔══════════════════════════════════════════════════════════════╗")
|
||||
print(f"║ NewBot — Paper Trading Dashboard {now} ║")
|
||||
print("╚══════════════════════════════════════════════════════════════╝")
|
||||
print()
|
||||
print(f" Balance : ${balance:>10,.2f} ({total_ret:+.2f}% since start)")
|
||||
print(f" Strategy : RSI Trend Pullback | {', '.join(config.SYMBOLS)}")
|
||||
print()
|
||||
|
||||
# open positions
|
||||
print("── Open Positions ─────────────────────────────────────────────")
|
||||
if not positions:
|
||||
print(" (none)")
|
||||
else:
|
||||
print(f" {'Symbol':<10} {'Dir':<6} {'Entry':>10} {'Current':>10} "
|
||||
f"{'SL':>10} {'TP':>10} {'Pips':>8}")
|
||||
for pos in positions:
|
||||
upnl = await _unrealised_pnl(pos)
|
||||
try:
|
||||
from bot.paper_client import get_tick
|
||||
tick = await get_tick(pos["symbol"])
|
||||
current = (tick["bid"] + tick["ask"]) / 2 if tick else pos["entry"]
|
||||
except Exception:
|
||||
current = pos["entry"]
|
||||
d = "LONG" if pos["direction"] == 0 else "SHORT"
|
||||
pip_str = f"{upnl:+.1f}"
|
||||
sign = "🟢" if upnl >= 0 else "🔴"
|
||||
print(f" {pos['symbol']:<10} {d:<6} {pos['entry']:>10.5f} "
|
||||
f"{current:>10.5f} {pos['sl']:>10.5f} {pos['tp']:>10.5f} "
|
||||
f"{sign}{pip_str:>7}")
|
||||
|
||||
print()
|
||||
|
||||
# recent closed trades
|
||||
print("── Recent Closed Trades ───────────────────────────────────────")
|
||||
recent = sorted(history, key=lambda x: x.get("closed_at",""), reverse=True)[:10]
|
||||
if not recent:
|
||||
print(" (none yet)")
|
||||
else:
|
||||
print(f" {'Symbol':<10} {'Dir':<6} {'Result':<6} {'Pips':>8} When")
|
||||
for t in recent:
|
||||
d = "LONG" if t["direction"] == 0 else "SHORT"
|
||||
pips = t.get("pnl_pips", 0)
|
||||
sign = "✅" if pips > 0 else "❌"
|
||||
closed = t.get("closed_at","")[:16]
|
||||
print(f" {t['symbol']:<10} {d:<6} {t.get('reason','?'):<6} "
|
||||
f"{sign}{pips:>+7.1f} {closed}")
|
||||
|
||||
print()
|
||||
|
||||
# stats
|
||||
if history:
|
||||
pips_list = [t.get("pnl_pips", 0) for t in history]
|
||||
wins = [p for p in pips_list if p > 0]
|
||||
total = len(pips_list)
|
||||
wr = len(wins) / total * 100
|
||||
total_pips = sum(pips_list)
|
||||
avg_w = sum(wins) / len(wins) if wins else 0
|
||||
losses = [p for p in pips_list if p <= 0]
|
||||
avg_l = sum(losses) / len(losses) if losses else 0
|
||||
pf = abs(sum(wins) / sum(losses)) if losses and sum(losses) != 0 else float("inf")
|
||||
print("── Stats ──────────────────────────────────────────────────────")
|
||||
print(f" Trades : {total} | Wins: {len(wins)} Losses: {len(losses)}")
|
||||
print(f" Win % : {wr:.1f}% | PF: {pf:.2f}")
|
||||
print(f" Avg W : {avg_w:+.1f} pips Avg L : {avg_l:+.1f} pips")
|
||||
print(f" Total : {total_pips:+.1f} pips")
|
||||
print()
|
||||
|
||||
print(" [Ctrl+C to stop] Refreshing every 60s…")
|
||||
|
||||
|
||||
# ── main scan loop ────────────────────────────────────────────────────────────
|
||||
|
||||
async def scan_once(label: str = "manual") -> None:
|
||||
logger.info("── Scanning symbols for signals (%s) ──", label)
|
||||
account = await broker.get_account_info()
|
||||
if account is None:
|
||||
logger.error("Broker not connected")
|
||||
return
|
||||
|
||||
state = _load_state()
|
||||
open_positions = await broker.get_open_positions()
|
||||
|
||||
for symbol in config.SYMBOLS:
|
||||
if _has_open(symbol, open_positions):
|
||||
logger.info("%-10s already has open position — skip", symbol)
|
||||
continue
|
||||
|
||||
logger.info("Fetching candles for %s…", symbol)
|
||||
df = await broker.get_candles(symbol, config.TIMEFRAME, config.LOOKBACK)
|
||||
if df is None or len(df) < 300:
|
||||
logger.warning("%-10s insufficient data (%s bars)", symbol,
|
||||
len(df) if df is not None else 0)
|
||||
continue
|
||||
|
||||
signal = check(df, symbol)
|
||||
if signal is None:
|
||||
logger.info("%-10s no signal (RSI=%.1f)", symbol,
|
||||
_last_rsi(df))
|
||||
continue
|
||||
|
||||
logger.info("SIGNAL: %s", signal)
|
||||
if not risk.can_trade(account, open_positions, state):
|
||||
logger.info("Trade limit reached")
|
||||
break
|
||||
|
||||
success = await _execute(signal, account)
|
||||
if success:
|
||||
open_positions = await broker.get_open_positions()
|
||||
state = _load_state()
|
||||
|
||||
|
||||
def _last_rsi(df) -> float:
|
||||
try:
|
||||
from engine.indicators import rsi
|
||||
return float(rsi(df["close"], 14).iloc[-2])
|
||||
except Exception:
|
||||
return float("nan")
|
||||
|
||||
|
||||
async def main():
|
||||
if not broker.connect():
|
||||
logger.error("Broker connect failed")
|
||||
return
|
||||
|
||||
# immediate scan on startup
|
||||
await scan_once("startup")
|
||||
await _dashboard()
|
||||
|
||||
# then loop every 60s: refresh dashboard + rescan on each new bar
|
||||
last_scan_minute = -1
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = datetime.now(timezone.utc)
|
||||
await _dashboard()
|
||||
|
||||
# rescan at each new M15 bar (minute % 15 == 0..2 window)
|
||||
if now.minute % 15 < 2 and now.minute != last_scan_minute:
|
||||
last_scan_minute = now.minute
|
||||
await scan_once(f"bar@{now.strftime('%H:%M')}")
|
||||
await _dashboard()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
broker.disconnect()
|
||||
print("\nBot stopped. Final state:")
|
||||
await _dashboard()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user