265 lines
10 KiB
Python
265 lines
10 KiB
Python
# trader.py
|
||
from __future__ import annotations
|
||
|
||
import threading
|
||
import time
|
||
import logging
|
||
from typing import Dict, List, Any, Optional
|
||
|
||
import pandas as pd
|
||
|
||
from config import CFG
|
||
from strategy import evaluate_signal, Decision
|
||
from portfolio import Portfolio
|
||
|
||
# ————————————————————————————————————————————————————————————————
|
||
# save_outputs – opcjonalny helper; jeżeli masz własny w util.py, użyje jego
|
||
try:
|
||
from util import save_outputs # def save_outputs(root, symbols, trades_df, equity_df, cash): ...
|
||
except Exception:
|
||
def save_outputs(root_dir: str, symbols: List[str], trades_df: pd.DataFrame,
|
||
equity_df: pd.DataFrame, cash: float):
|
||
# fallback no-op, żeby nie wysypywać serwera
|
||
pass
|
||
# ————————————————————————————————————————————————————————————————
|
||
# fetch_batch – Twój moduł pobierania danych (w logach: "data | Yahoo: ...")
|
||
from data import fetch_batch # def fetch_batch(tickers: List[str], period: str, interval: str) -> Dict[str, pd.DataFrame]
|
||
|
||
log = logging.getLogger("trader")
|
||
|
||
|
||
class TraderWorker:
|
||
def __init__(self):
|
||
self.portfolio = Portfolio(
|
||
starting_cash=CFG.starting_cash,
|
||
commission_per_trade=CFG.commission_per_trade,
|
||
slippage_bp=CFG.slippage_bp,
|
||
)
|
||
|
||
self.syms: List[str] = CFG.tickers[:] # 40 szt.
|
||
self.hist: Dict[str, pd.DataFrame] = {}
|
||
self.last_ts: Dict[str, pd.Timestamp] = {}
|
||
|
||
# stan pętli
|
||
self._thread: Optional[threading.Thread] = None
|
||
self._stop_evt = threading.Event()
|
||
self._running = False
|
||
|
||
# telemetry
|
||
self._idx = 0
|
||
self._round = 0
|
||
self._stage = "idle"
|
||
self._current_ticker = None
|
||
self._last_action = None
|
||
self._last_heartbeat = time.time()
|
||
|
||
log.info("INIT: %d symbols | period=%s interval=%s cash=%.2f",
|
||
len(self.syms), CFG.yf_period, CFG.interval, self.portfolio.cash)
|
||
|
||
# ———————————————————— API do serwera ————————————————————
|
||
|
||
def is_running(self) -> bool:
|
||
return self._running
|
||
|
||
def start(self) -> bool:
|
||
if self._running:
|
||
return False
|
||
self._stop_evt.clear()
|
||
self._thread = threading.Thread(target=self._loop, name="TraderLoop", daemon=True)
|
||
self._thread.start()
|
||
self._running = True
|
||
log.info("LOOP: sequential 1-ticker | sleep=%ss", CFG.loop_sleep_s)
|
||
log.info("LOOP: started")
|
||
return True
|
||
|
||
def stop(self) -> bool:
|
||
if not self._running:
|
||
return False
|
||
self._stop_evt.set()
|
||
if self._thread and self._thread.is_alive():
|
||
self._thread.join(timeout=5)
|
||
self._running = False
|
||
log.info("LOOP: stopped")
|
||
return True
|
||
|
||
def _loop(self):
|
||
while not self._stop_evt.is_set():
|
||
self.tick_once()
|
||
time.sleep(max(0, CFG.loop_sleep_s))
|
||
|
||
def status(self) -> Dict[str, Any]:
|
||
return {
|
||
"running": self._running,
|
||
"round": self._round,
|
||
"stage": self._stage,
|
||
"current_ticker": self._current_ticker,
|
||
"current_index": self._idx % len(self.syms) if self.syms else 0,
|
||
"tickers_total": len(self.syms),
|
||
"last_action": self._last_action,
|
||
"cash": float(self.portfolio.cash),
|
||
"positions_count": len(self.portfolio.positions),
|
||
"trades_count": len(self.portfolio.trades),
|
||
"last_heartbeat": self._last_heartbeat,
|
||
}
|
||
|
||
def list_positions(self) -> List[Dict[str, Any]]:
|
||
out = []
|
||
for tk, p in self.portfolio.positions.items():
|
||
last_px = float(self.portfolio.last_price.get(tk, p.entry_price))
|
||
if p.side == "long":
|
||
pnl = (last_px - p.entry_price) * p.size
|
||
else:
|
||
pnl = (p.entry_price - last_px) * p.size
|
||
out.append({
|
||
"ticker": tk,
|
||
"side": p.side,
|
||
"size": float(p.size),
|
||
"entry_price": float(p.entry_price),
|
||
"last_price": last_px,
|
||
"pnl": float(pnl),
|
||
"sl": p.sl,
|
||
"tp": p.tp,
|
||
})
|
||
return out
|
||
|
||
def list_trades(self) -> List[Dict[str, Any]]:
|
||
return list(self.portfolio.trades)
|
||
|
||
def list_equity(self) -> List[List[float]]:
|
||
# [ [ts_ms, equity], ... ]
|
||
return [[int(ts), float(eq)] for (ts, eq) in self.portfolio.portfolio_equity]
|
||
|
||
# przyciski testowe z server.py
|
||
def test_open_long(self, ticker: str, price: float, size: float):
|
||
self.portfolio.last_price[ticker] = float(price)
|
||
self.portfolio.open_long(ticker, float(size), float(price))
|
||
|
||
def test_open_short(self, ticker: str, price: float, size: float):
|
||
self.portfolio.last_price[ticker] = float(price)
|
||
self.portfolio.open_short(ticker, float(size), float(price))
|
||
|
||
def test_close(self, ticker: str, price: float | None):
|
||
px = float(price) if price is not None else float(self.portfolio.last_price.get(ticker, 0.0))
|
||
self.portfolio.close_all(ticker, px, reason="api:test_close")
|
||
|
||
# ———————————————————— Core ————————————————————
|
||
|
||
def _advance_index(self):
|
||
if not self.syms:
|
||
self._idx = 0
|
||
return
|
||
self._idx = (self._idx + 1) % len(self.syms)
|
||
|
||
def _equity_now(self) -> float:
|
||
eq = float(self.portfolio.cash)
|
||
for p in self.portfolio.positions.values():
|
||
px = float(self.portfolio.last_price.get(p.ticker, p.entry_price))
|
||
if p.side == "long":
|
||
eq += (px - p.entry_price) * p.size
|
||
else:
|
||
eq += (p.entry_price - px) * p.size
|
||
return float(eq)
|
||
|
||
def tick_once(self) -> float:
|
||
t0 = time.time()
|
||
self._round += 1
|
||
if not self.syms:
|
||
log.warning("No symbols configured")
|
||
return 0.0
|
||
|
||
tk = self.syms[self._idx % len(self.syms)]
|
||
self._current_ticker = tk
|
||
self._stage = "fetch"
|
||
log.info("[ROUND %d] TICKER %s (%d/%d) — stage=fetch",
|
||
self._round, tk, (self._idx % len(self.syms)) + 1, len(self.syms))
|
||
|
||
try:
|
||
# 1) POBIERZ DANE tylko dla jednego tickera
|
||
batch = fetch_batch([tk], CFG.yf_period, CFG.interval)
|
||
df = (batch or {}).get(tk)
|
||
if df is None or len(df) == 0:
|
||
log.warning("Data: %s -> EMPTY", tk)
|
||
self._advance_index()
|
||
return time.time() - t0
|
||
|
||
# sanity kolumn
|
||
if "Close" not in df.columns or df["Close"].dropna().empty:
|
||
log.warning("Data: %s -> no usable Close column (cols=%s)", tk, list(df.columns))
|
||
self._advance_index()
|
||
return time.time() - t0
|
||
|
||
last_close = float(pd.to_numeric(df["Close"], errors="coerce").dropna().iloc[-1])
|
||
log.info("Data: %s -> bars=%d last_close=%.6f first=%s last=%s",
|
||
tk, len(df), last_close, str(df.index[0]), str(df.index[-1]))
|
||
|
||
# 2) AKTUALIZUJ HISTORIĘ
|
||
if tk not in self.hist:
|
||
self.hist[tk] = df.copy()
|
||
else:
|
||
append = df[df.index > self.hist[tk].index.max()]
|
||
if not append.empty:
|
||
self.hist[tk] = pd.concat([self.hist[tk], append])
|
||
|
||
self.last_ts[tk] = df.index[-1]
|
||
# aktualizuj „mark-to-market” dla equity
|
||
self.portfolio.last_price[tk] = last_close
|
||
|
||
# 3) SYGNAŁ
|
||
self._stage = "signal"
|
||
try:
|
||
closes_preview = list(
|
||
pd.to_numeric(self.hist[tk].get("Close"), errors="coerce").dropna().tail(3).round(6))
|
||
except Exception:
|
||
closes_preview = []
|
||
|
||
decision: Decision = evaluate_signal(self.hist[tk])
|
||
log.info("Signal: %s -> %s (last3=%s)", tk, decision, closes_preview)
|
||
|
||
# 3.5) SIZING NA RYZYKO (1R = dystans do SL)
|
||
size = CFG.min_size
|
||
if decision.signal != "NONE" and decision.rpu > 0:
|
||
equity = self._equity_now()
|
||
risk_cash = max(0.0, equity) * CFG.risk_per_trade_frac
|
||
size = risk_cash / decision.rpu
|
||
size = max(CFG.min_size, min(CFG.max_size, size))
|
||
|
||
# 4) EGZEKUCJA — TYLKO gdy BUY/SELL; NIE zamykamy przy NONE
|
||
self._stage = "execute"
|
||
action = "HOLD"
|
||
if decision.signal == "BUY":
|
||
self.portfolio.open_long(tk, size, last_close, sl=decision.sl, tp=decision.tp)
|
||
action = "BUY"
|
||
elif decision.signal == "SELL" and CFG.allow_short:
|
||
self.portfolio.open_short(tk, size, last_close, sl=decision.sl, tp=decision.tp)
|
||
action = "SELL"
|
||
|
||
self._last_action = action
|
||
log.info("Action: %s %s @ %.6f (size=%.2f)", action, tk, last_close, float(size))
|
||
|
||
# 5) ZAPISZ WYJŚCIA (co rundę)
|
||
self._stage = "save"
|
||
# dociśnij próbkę equity dla UI (użyj timestampu ostatniej świecy)
|
||
try:
|
||
ts_ms = int(self.last_ts[tk].value / 1e6)
|
||
self.portfolio.portfolio_equity.append((ts_ms, self._equity_now()))
|
||
except Exception:
|
||
# w ostateczności stempel teraz
|
||
self.portfolio.portfolio_equity.append((int(time.time() * 1000), self._equity_now()))
|
||
|
||
trades_df = pd.DataFrame(self.portfolio.trades)
|
||
eq_df = pd.DataFrame(self.portfolio.portfolio_equity, columns=["time", "equity"])
|
||
save_outputs(CFG.root_dir, self.syms, trades_df, eq_df, self.portfolio.cash)
|
||
log.debug("Saved outputs | trades=%d equity=%d cash=%.2f",
|
||
len(trades_df), len(eq_df), self.portfolio.cash)
|
||
|
||
except Exception as e:
|
||
log.exception("ERROR in tick_once(%s): %s", tk, e)
|
||
|
||
finally:
|
||
took = time.time() - t0
|
||
self._last_heartbeat = time.time()
|
||
self._stage = "sleep"
|
||
self._advance_index()
|
||
log.info("[ROUND %d] done in %.2fs | next index=%d", self._round, took, self._idx % len(self.syms))
|
||
return took
|