diff --git a/scripts/run_paper_trading.py b/scripts/run_paper_trading.py new file mode 100644 index 0000000..dccccad --- /dev/null +++ b/scripts/run_paper_trading.py @@ -0,0 +1,202 @@ +"""Paper-trading runner Phase 3 — forward-test virtuale BTC + ETH. + +Loop infinito (o limitato via --max-ticks) che ogni ``--poll-seconds``: + 1. fetch OHLCV 1h ultime ~500 barre via Cerbero + 2. per ogni strategia: compile + esegui ultimo bar + 3. apply segnale al portfolio multi-asset + 4. snapshot equity in DB + +I bar 1h chiudono al minuto :00. Il loop riconosce un "nuovo bar chiuso" +confrontando l'ultimo timestamp del DataFrame con quello dell'iterazione +precedente. Tick consecutivi su stesso bar = hold (no double-trade). + +Esempio: + uv run python scripts/run_paper_trading.py \ + --name phase3-papertrade-001 \ + --initial-capital 1000 \ + --max-ticks 336 # 2 settimane * 24 ore +""" + +from __future__ import annotations + +import argparse +import time +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from multi_swarm.cerbero.client import CerberoClient +from multi_swarm.config import load_settings +from multi_swarm.data.cerbero_ohlcv import CerberoOHLCVLoader, OHLCVRequest +from multi_swarm.paper_trading.executor import PaperExecutor +from multi_swarm.paper_trading.persistence import PaperRepository +from multi_swarm.paper_trading.portfolio import Portfolio +from multi_swarm.persistence.repository import Repository + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +@dataclass(frozen=True) +class AssetConfig: + symbol: str # es. "BTC-PERPETUAL" + strategy_file: Path + exchange: str = "deribit" + timeframe: str = "1h" + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Paper-trading runner Phase 3") + p.add_argument("--name", default="phase3-papertrade-001") + p.add_argument("--initial-capital", type=float, default=1000.0) + p.add_argument("--fees-bp", type=float, default=5.0) + p.add_argument("--poll-seconds", type=int, default=300, help="Polling interval (5min default)") + p.add_argument("--max-ticks", type=int, default=0, help="0 = infinito; per smoke test usa 1") + p.add_argument("--lookback-bars", type=int, default=500, help="Quante bar fetchare per indicatori") + p.add_argument( + "--strategies-dir", + default=str(PROJECT_ROOT / "strategies"), + help="Cartella contenente btc_*.json e eth_*.json", + ) + return p.parse_args() + + +def load_assets(strategies_dir: Path) -> list[AssetConfig]: + btc_files = sorted(strategies_dir.glob("btc_*.json")) + eth_files = sorted(strategies_dir.glob("eth_*.json")) + if not btc_files or not eth_files: + raise FileNotFoundError( + f"Expected btc_*.json and eth_*.json in {strategies_dir}" + ) + return [ + AssetConfig(symbol="BTC-PERPETUAL", strategy_file=btc_files[0]), + AssetConfig(symbol="ETH-PERPETUAL", strategy_file=eth_files[0]), + ] + + +def main() -> None: + args = parse_args() + settings = load_settings() + + # Inizializza schema (idempotente). + Repository(settings.db_path).init_schema() + + token = ( + settings.cerbero_mainnet_token.get_secret_value() + if settings.cerbero_mainnet_token + else settings.cerbero_testnet_token.get_secret_value() + ) + cerbero = CerberoClient( + base_url=settings.cerbero_base_url, + token=token, + bot_tag=settings.cerbero_bot_tag, + ) + loader = CerberoOHLCVLoader(client=cerbero, cache_dir=settings.series_dir) + + assets = load_assets(Path(args.strategies_dir)) + executors: list[PaperExecutor] = [ + PaperExecutor(strategy_json_path=a.strategy_file, symbol=a.symbol) for a in assets + ] + print(f"Loaded {len(assets)} strategies:") + for a, ex in zip(assets, executors, strict=True): + print(f" {a.symbol}: {a.strategy_file.name} -> {len(ex._strategy.rules)} rules") + + portfolio = Portfolio( + initial_capital=args.initial_capital, + fees_bp=args.fees_bp, + n_sleeves=len(assets), + ) + repo = PaperRepository(settings.db_path) + config = { + "assets": [ + {"symbol": a.symbol, "strategy": a.strategy_file.name, "exchange": a.exchange} + for a in assets + ], + "fees_bp": args.fees_bp, + "poll_seconds": args.poll_seconds, + "lookback_bars": args.lookback_bars, + } + run_id = repo.create_run( + name=args.name, initial_capital=args.initial_capital, config=config + ) + print(f"Paper run started: {run_id} ({args.name})") + print(f" initial_capital=${args.initial_capital:.2f}, sleeve=${portfolio.sleeve_capital:.2f}") + + tick_count = 0 + last_bars_seen: dict[str, datetime] = {} + try: + while args.max_ticks == 0 or tick_count < args.max_ticks: + now = datetime.now(UTC) + last_prices: dict[str, float] = {} + for asset, executor in zip(assets, executors, strict=True): + # fetch OHLCV most recent lookback bars + end = now.replace(minute=0, second=0, microsecond=0) + start = end - timedelta(hours=args.lookback_bars + 1) + req = OHLCVRequest( + symbol=asset.symbol, + timeframe=asset.timeframe, + start=start, + end=end, + exchange=asset.exchange, + ) + # bypass cache for live data + try: + ohlcv = loader._fetch(req) # noqa: SLF001 + except Exception as e: # noqa: BLE001 + print(f"[{now.isoformat()}] {asset.symbol} fetch FAIL: {e}") + continue + if len(ohlcv) < 10: + print(f"[{now.isoformat()}] {asset.symbol} too few bars ({len(ohlcv)})") + continue + + bar_ts = ohlcv.index[-1] + last_bar_dt = bar_ts.to_pydatetime() if hasattr(bar_ts, "to_pydatetime") else bar_ts + # skip se barra gia' processata in questo tick + if last_bars_seen.get(asset.symbol) == last_bar_dt: + last_prices[asset.symbol] = float(ohlcv["close"].iloc[-1]) + continue + last_bars_seen[asset.symbol] = last_bar_dt + + result = executor.execute_tick(portfolio, ohlcv, now) + repo.save_tick(run_id, result) + last_prices[asset.symbol] = result.close_price + if result.action_taken != "hold": + pnl_str = ( + f"pnl=${result.trade.net_pnl:+.2f}" if result.trade else "" + ) + print( + f"[{now.isoformat()}] {asset.symbol} bar={last_bar_dt} " + f"close={result.close_price:.2f} signal={result.signal.value} " + f"action={result.action_taken} {pnl_str}" + ) + + repo.sync_open_positions(run_id, portfolio) + eq, pos_val = portfolio.equity(last_prices) + repo.save_equity_snapshot(run_id, now, eq, portfolio.cash, pos_val) + + tick_count += 1 + print( + f"[{now.isoformat()}] tick={tick_count} " + f"equity=${eq:.2f} cash=${portfolio.cash:.2f} pos_val=${pos_val:.2f} " + f"open={list(portfolio.positions.keys())}" + ) + + if args.max_ticks > 0 and tick_count >= args.max_ticks: + break + time.sleep(args.poll_seconds) + + repo.stop_run(run_id, status="completed") + except KeyboardInterrupt: + print("\nInterrupted by user") + repo.stop_run(run_id, status="interrupted") + except Exception as e: # noqa: BLE001 + print(f"Run failed: {e}") + repo.stop_run(run_id, status="failed") + raise + + print(f"Paper run {run_id} stopped after {tick_count} ticks") + print(f"Final equity: ${portfolio.equity({})[0]:.2f}") + print(f"Trades closed: {len(portfolio.closed_trades)}") + + +if __name__ == "__main__": + main() diff --git a/src/multi_swarm/paper_trading/__init__.py b/src/multi_swarm/paper_trading/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/multi_swarm/paper_trading/executor.py b/src/multi_swarm/paper_trading/executor.py new file mode 100644 index 0000000..dbbd32c --- /dev/null +++ b/src/multi_swarm/paper_trading/executor.py @@ -0,0 +1,96 @@ +"""PaperExecutor: applica un segnale di strategia a un Portfolio. + +Il flusso per ogni tick: + + bar OHLCV chiuso -> compile_strategy(strategy) -> Series[Side] + -> last_signal = series.iloc[-1] + -> match con posizione attuale -> open / close / hold + +Niente delay 1-bar: in paper-trading il segnale viene calcolato sulla +barra appena chiusa e applicato al prezzo close della stessa. La latenza +reale tra tick e ordine va misurata separatamente (Phase 3 spec). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +import pandas as pd # type: ignore[import-untyped] + +from ..backtest.orders import Side, Trade +from ..protocol.compiler import compile_strategy +from ..protocol.parser import parse_strategy +from .portfolio import OpenPosition, Portfolio + + +@dataclass +class TickResult: + ts: datetime + symbol: str + bar_ts: datetime + close_price: float + signal: Side + action_taken: str # "open_long" | "open_short" | "close" | "reverse" | "hold" + trade: Trade | None = None + new_position: OpenPosition | None = None + + +class PaperExecutor: + def __init__(self, strategy_json_path: Path, symbol: str) -> None: + text = strategy_json_path.read_text() + # parse_strategy si aspetta JSON pulito, non fence; il file e' gia' JSON. + self._strategy = parse_strategy(text) + self._compiled = compile_strategy(self._strategy) + self.symbol = symbol + self.strategy_path = strategy_json_path + + def execute_tick( + self, + portfolio: Portfolio, + ohlcv: pd.DataFrame, + now: datetime, + ) -> TickResult: + """Esegui un tick: calcola segnale su tutto ``ohlcv`` (per indicatori + con lookback), prendi l'ultimo, e applica al portfolio.""" + if len(ohlcv) == 0: + raise ValueError("Empty OHLCV passed to execute_tick") + signals = self._compiled(ohlcv) + # ultimo bar chiuso + bar_ts = ohlcv.index[-1] + close_price = float(ohlcv["close"].iloc[-1]) + signal = Side(signals.iloc[-1]) if signals.iloc[-1] is not None else Side.FLAT + + current = portfolio.positions.get(self.symbol) + action = "hold" + trade: Trade | None = None + new_position: OpenPosition | None = None + + if current is None and signal != Side.FLAT: + new_position = portfolio.open(self.symbol, signal, close_price, now) + action = f"open_{signal.value}" + elif current is not None and signal == Side.FLAT: + trade = portfolio.close(self.symbol, close_price, now) + action = "close" + elif current is not None and signal != current.side: + # reverse: chiudi e riapri opposto + trade = portfolio.close(self.symbol, close_price, now) + new_position = portfolio.open(self.symbol, signal, close_price, now) + action = "reverse" + + return TickResult( + ts=now, + symbol=self.symbol, + bar_ts=bar_ts.to_pydatetime() if hasattr(bar_ts, "to_pydatetime") else bar_ts, + close_price=close_price, + signal=signal, + action_taken=action, + trade=trade, + new_position=new_position, + ) + + @property + def strategy_dict(self) -> dict: + return json.loads(self.strategy_path.read_text()) diff --git a/src/multi_swarm/paper_trading/persistence.py b/src/multi_swarm/paper_trading/persistence.py new file mode 100644 index 0000000..1f1a234 --- /dev/null +++ b/src/multi_swarm/paper_trading/persistence.py @@ -0,0 +1,114 @@ +"""Persistenza paper-trading: usa lo stesso ``runs.db`` con tabelle dedicate +``paper_trading_*`` (vedi :mod:`multi_swarm.persistence.schema`). +""" + +from __future__ import annotations + +import json +import sqlite3 +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from .executor import TickResult +from .portfolio import Portfolio + + +class PaperRepository: + def __init__(self, db_path: Path | str): + self.db_path = Path(db_path) + + def _conn(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + return conn + + @staticmethod + def _now() -> str: + return datetime.now(UTC).isoformat() + + def create_run(self, name: str, initial_capital: float, config: dict[str, Any]) -> str: + rid = uuid.uuid4().hex + with self._conn() as conn: + conn.execute( + "INSERT INTO paper_trading_runs " + "(id, name, started_at, status, initial_capital, config_json) " + "VALUES (?,?,?,?,?,?)", + (rid, name, self._now(), "running", initial_capital, json.dumps(config)), + ) + return rid + + def stop_run(self, run_id: str, status: str = "stopped") -> None: + with self._conn() as conn: + conn.execute( + "UPDATE paper_trading_runs SET stopped_at=?, status=? WHERE id=?", + (self._now(), status, run_id), + ) + + def save_tick(self, run_id: str, tick: TickResult) -> None: + with self._conn() as conn: + conn.execute( + "INSERT INTO paper_trading_ticks " + "(paper_run_id, symbol, ts, bar_ts, close_price, signal, action_taken) " + "VALUES (?,?,?,?,?,?,?)", + ( + run_id, + tick.symbol, + tick.ts.isoformat(), + tick.bar_ts.isoformat() if hasattr(tick.bar_ts, "isoformat") else str(tick.bar_ts), + tick.close_price, + tick.signal.value, + tick.action_taken, + ), + ) + if tick.trade is not None: + t = tick.trade + conn.execute( + "INSERT INTO paper_trading_trades " + "(paper_run_id, symbol, side, qty, entry_price, exit_price, " + "entry_ts, exit_ts, pnl, fees) VALUES (?,?,?,?,?,?,?,?,?,?)", + ( + run_id, + tick.symbol, + t.side.value, + t.size, + t.entry_price, + t.exit_price, + t.entry_ts.isoformat(), + t.exit_ts.isoformat(), + t.net_pnl, + t.fees, + ), + ) + + def save_equity_snapshot( + self, + run_id: str, + ts: datetime, + equity: float, + cash: float, + positions_value: float, + ) -> None: + with self._conn() as conn: + conn.execute( + "INSERT INTO paper_trading_equity " + "(paper_run_id, ts, equity, cash, positions_value) VALUES (?,?,?,?,?)", + (run_id, ts.isoformat(), equity, cash, positions_value), + ) + + def sync_open_positions(self, run_id: str, portfolio: Portfolio) -> None: + """Sostituisce snapshot posizioni aperte. Idempotente: cancella e reinserisce.""" + with self._conn() as conn: + conn.execute( + "DELETE FROM paper_trading_positions WHERE paper_run_id=?", (run_id,) + ) + for sym, pos in portfolio.positions.items(): + conn.execute( + "INSERT INTO paper_trading_positions " + "(paper_run_id, symbol, side, qty, entry_price, entry_ts) " + "VALUES (?,?,?,?,?,?)", + (run_id, sym, pos.side.value, pos.qty, pos.entry_price, pos.entry_ts.isoformat()), + ) diff --git a/src/multi_swarm/paper_trading/portfolio.py b/src/multi_swarm/paper_trading/portfolio.py new file mode 100644 index 0000000..4b99bfa --- /dev/null +++ b/src/multi_swarm/paper_trading/portfolio.py @@ -0,0 +1,104 @@ +"""Portfolio multi-asset per paper-trading. + +Modello semplificato: capitale unico ``cash``, allocazione equal-weight +fra N posizioni (sleeve = 1/N del capitale iniziale per ogni simbolo). +Niente leva, niente liquidation, fees su entry+exit (bp del notional). + +Una :class:`Position` rappresenta una posizione aperta su un singolo +simbolo (long/short, qty in unita' dell'asset, prezzo di entry). La +posizione viene chiusa con :meth:`Portfolio.close` che produce un +:class:`Trade` realized e accredita ``cash``. + +Mark-to-market via :meth:`Portfolio.equity`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + +from ..backtest.orders import Side, Trade + + +@dataclass(frozen=True) +class OpenPosition: + symbol: str + side: Side + qty: float + entry_price: float + entry_ts: datetime + + +@dataclass +class Portfolio: + initial_capital: float + fees_bp: float = 5.0 + n_sleeves: int = 2 # numero strategie / asset previsti + cash: float = field(init=False) + positions: dict[str, OpenPosition] = field(default_factory=dict) + closed_trades: list[Trade] = field(default_factory=list) + + def __post_init__(self) -> None: + self.cash = self.initial_capital + + @property + def sleeve_capital(self) -> float: + return self.initial_capital / self.n_sleeves + + def open( + self, + symbol: str, + side: Side, + price: float, + ts: datetime, + ) -> OpenPosition: + if symbol in self.positions: + raise ValueError(f"Position already open on {symbol}") + if side == Side.FLAT: + raise ValueError("Cannot open a FLAT position") + # sleeve fisso: alloca 1/n_sleeves del capitale iniziale, qty = notional/price. + notional = self.sleeve_capital + qty = notional / price + fees = notional * (self.fees_bp / 10000.0) + self.cash -= fees + pos = OpenPosition(symbol=symbol, side=side, qty=qty, entry_price=price, entry_ts=ts) + self.positions[symbol] = pos + return pos + + def close( + self, + symbol: str, + price: float, + ts: datetime, + ) -> Trade: + if symbol not in self.positions: + raise ValueError(f"No open position on {symbol}") + pos = self.positions.pop(symbol) + trade = Trade( + entry_ts=pos.entry_ts, + exit_ts=ts, + side=pos.side, + size=pos.qty, + entry_price=pos.entry_price, + exit_price=price, + fees_bp=self.fees_bp, + ) + # net_pnl include gia' i fees sull'intero round-trip; abbiamo gia' + # addebitato meta' fees all'open, ora addebitiamo il resto. + self.cash += trade.gross_pnl - (trade.fees / 2.0) + self.closed_trades.append(trade) + return trade + + def equity(self, last_prices: dict[str, float]) -> tuple[float, float]: + """Ritorna (equity_totale, positions_value) marcando posizioni aperte + al ``last_prices[symbol]``. Posizioni senza prezzo disponibile valgono + notional di entry (fallback conservativo).""" + positions_value = 0.0 + for sym, pos in self.positions.items(): + price = last_prices.get(sym, pos.entry_price) + unreal = pos.qty * ( + price - pos.entry_price if pos.side == Side.LONG + else pos.entry_price - price + ) + positions_value += pos.qty * pos.entry_price + unreal + return self.cash + positions_value, positions_value diff --git a/src/multi_swarm/persistence/schema.py b/src/multi_swarm/persistence/schema.py index 3bcec0c..e9b6f11 100644 --- a/src/multi_swarm/persistence/schema.py +++ b/src/multi_swarm/persistence/schema.py @@ -77,7 +77,68 @@ CREATE TABLE IF NOT EXISTS adversarial_findings ( FOREIGN KEY (run_id) REFERENCES runs(id) ); +CREATE TABLE IF NOT EXISTS paper_trading_runs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + started_at TEXT NOT NULL, + stopped_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + initial_capital REAL NOT NULL, + config_json TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS paper_trading_positions ( + paper_run_id TEXT NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + qty REAL NOT NULL, + entry_price REAL NOT NULL, + entry_ts TEXT NOT NULL, + PRIMARY KEY (paper_run_id, symbol), + FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id) +); + +CREATE TABLE IF NOT EXISTS paper_trading_trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_run_id TEXT NOT NULL, + symbol TEXT NOT NULL, + side TEXT NOT NULL, + qty REAL NOT NULL, + entry_price REAL NOT NULL, + exit_price REAL NOT NULL, + entry_ts TEXT NOT NULL, + exit_ts TEXT NOT NULL, + pnl REAL NOT NULL, + fees REAL NOT NULL, + FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id) +); + +CREATE TABLE IF NOT EXISTS paper_trading_equity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_run_id TEXT NOT NULL, + ts TEXT NOT NULL, + equity REAL NOT NULL, + cash REAL NOT NULL, + positions_value REAL NOT NULL, + FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id) +); + +CREATE TABLE IF NOT EXISTS paper_trading_ticks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + paper_run_id TEXT NOT NULL, + symbol TEXT NOT NULL, + ts TEXT NOT NULL, + bar_ts TEXT NOT NULL, + close_price REAL NOT NULL, + signal TEXT NOT NULL, + action_taken TEXT NOT NULL, + FOREIGN KEY (paper_run_id) REFERENCES paper_trading_runs(id) +); + CREATE INDEX IF NOT EXISTS idx_evaluations_fitness ON evaluations(run_id, fitness DESC); CREATE INDEX IF NOT EXISTS idx_genomes_generation ON genomes(run_id, generation_idx); CREATE INDEX IF NOT EXISTS idx_cost_run ON cost_records(run_id); +CREATE INDEX IF NOT EXISTS idx_paper_trades_run ON paper_trading_trades(paper_run_id, exit_ts); +CREATE INDEX IF NOT EXISTS idx_paper_equity_run ON paper_trading_equity(paper_run_id, ts); +CREATE INDEX IF NOT EXISTS idx_paper_ticks_run ON paper_trading_ticks(paper_run_id, ts); """ diff --git a/strategies/btc_fb63e851.json b/strategies/btc_fb63e851.json new file mode 100644 index 0000000..3f79cb7 --- /dev/null +++ b/strategies/btc_fb63e851.json @@ -0,0 +1,142 @@ +{ + "rules": [ + { + "condition": { + "op": "and", + "args": [ + { + "op": "gt", + "args": [ + { + "kind": "indicator", + "name": "rsi", + "params": [ + 14 + ] + }, + { + "kind": "literal", + "value": 70.0 + } + ] + }, + { + "op": "gt", + "args": [ + { + "kind": "indicator", + "name": "atr", + "params": [ + 14 + ] + }, + { + "kind": "indicator", + "name": "sma", + "params": [ + 14 + ] + } + ] + }, + { + "op": "gt", + "args": [ + { + "kind": "feature", + "name": "hour" + }, + { + "kind": "literal", + "value": 9 + } + ] + }, + { + "op": "lt", + "args": [ + { + "kind": "feature", + "name": "hour" + }, + { + "kind": "literal", + "value": 17 + } + ] + } + ] + }, + "action": "entry-short" + }, + { + "condition": { + "op": "and", + "args": [ + { + "op": "lt", + "args": [ + { + "kind": "indicator", + "name": "rsi", + "params": [ + 14 + ] + }, + { + "kind": "literal", + "value": 30.0 + } + ] + }, + { + "op": "lt", + "args": [ + { + "kind": "indicator", + "name": "atr", + "params": [ + 14 + ] + }, + { + "kind": "indicator", + "name": "sma", + "params": [ + 14 + ] + } + ] + }, + { + "op": "gt", + "args": [ + { + "kind": "feature", + "name": "hour" + }, + { + "kind": "literal", + "value": 9 + } + ] + }, + { + "op": "lt", + "args": [ + { + "kind": "feature", + "name": "hour" + }, + { + "kind": "literal", + "value": 17 + } + ] + } + ] + }, + "action": "entry-long" + } + ] +} \ No newline at end of file diff --git a/strategies/eth_facd6af85d5d.json b/strategies/eth_facd6af85d5d.json new file mode 100644 index 0000000..0140df1 --- /dev/null +++ b/strategies/eth_facd6af85d5d.json @@ -0,0 +1,162 @@ +{ + "rules": [ + { + "condition": { + "op": "and", + "args": [ + { + "op": "gt", + "args": [ + { + "kind": "indicator", + "name": "atr", + "params": [ + 14 + ] + }, + { + "kind": "literal", + "value": 0.02 + } + ] + }, + { + "op": "gt", + "args": [ + { + "kind": "indicator", + "name": "realized_vol", + "params": [ + 20 + ] + }, + { + "kind": "literal", + "value": 0.03 + } + ] + }, + { + "op": "gt", + "args": [ + { + "kind": "indicator", + "name": "sma", + "params": [ + 50 + ] + }, + { + "kind": "indicator", + "name": "sma", + "params": [ + 200 + ] + } + ] + } + ] + }, + "action": "entry-long" + }, + { + "condition": { + "op": "and", + "args": [ + { + "op": "lt", + "args": [ + { + "kind": "indicator", + "name": "atr", + "params": [ + 14 + ] + }, + { + "kind": "literal", + "value": 0.01 + } + ] + }, + { + "op": "lt", + "args": [ + { + "kind": "indicator", + "name": "realized_vol", + "params": [ + 20 + ] + }, + { + "kind": "literal", + "value": 0.02 + } + ] + }, + { + "op": "lt", + "args": [ + { + "kind": "indicator", + "name": "sma", + "params": [ + 50 + ] + }, + { + "kind": "indicator", + "name": "sma", + "params": [ + 200 + ] + } + ] + } + ] + }, + "action": "entry-short" + }, + { + "condition": { + "op": "or", + "args": [ + { + "op": "crossover", + "args": [ + { + "kind": "indicator", + "name": "rsi", + "params": [ + 14 + ] + }, + { + "kind": "literal", + "value": 70.0 + } + ] + }, + { + "op": "crossunder", + "args": [ + { + "kind": "indicator", + "name": "rsi", + "params": [ + 14 + ] + }, + { + "kind": "literal", + "value": 30.0 + } + ] + } + ] + }, + "action": "exit" + } + ] +} \ No newline at end of file diff --git a/tests/unit/paper_trading/__init__.py b/tests/unit/paper_trading/__init__.py new file mode 100644 index 0000000..e69de29