stock/trader.py
2025-08-15 12:19:07 +02:00

265 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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