yns?&2GN^(X)9_z5Eze0tID;?94hF06+a
zZSZ4%?%$06i;>_Z4@V}|xL(!;Y02}_j|M}CO89oDl7D9Mz
S?2GvC+kbX!?Cxht%Kjg+gUitX
literal 0
HcmV?d00001
diff --git a/new/portfolio.py b/new/portfolio.py
new file mode 100644
index 0000000..32b711e
--- /dev/null
+++ b/new/portfolio.py
@@ -0,0 +1,333 @@
+# portfolio.py
+from __future__ import annotations
+import math, time, json
+from typing import Dict, List, Any, Optional
+
+# ========= TRYB KSIĘGOWOŚCI =========
+# "stock" – akcje (short dodaje gotówkę przy otwarciu, przy zamknięciu oddajesz notional)
+# "margin" – FX/CFD (short NIE zmienia cash przy otwarciu; cash zmienia się o zrealizowany PnL przy zamknięciu)
+ACCOUNTING_MODE = "margin"
+
+# ========= RYZYKO / ATR =========
+USE_ATR = True # jeśli sygnał ma "atr" > 0, to SL/TP liczone od ATR
+SL_ATR_MULT = 2.0 # SL = 2.0 * ATR
+TP_ATR_MULT = 3.0 # TP = 3.0 * ATR
+RISK_FRACTION = 0.003 # 0.3% equity na trade (position sizing wg 1R)
+
+# fallback procentowy, gdy brak ATR
+SL_PCT = 0.010
+TP_PCT = 0.020
+TRAIL_PCT = 0.020 # spokojniejszy trailing
+
+SYMBOL_OVERRIDES = {
+ # "EURUSD=X": {"SL_PCT": 0.0025, "TP_PCT": 0.0050, "TRAIL_PCT": 0.0030},
+}
+
+# ========= LIMITY BUDŻETOWE =========
+ALLOC_FRACTION = 0.25 # max % gotówki na JEDEN LONG
+MIN_TRADE_CASH = 25.0
+MAX_NEW_POSITIONS_PER_CYCLE = 2 # None aby wyłączyć limit
+
+# =================================
+def _to_native(obj: Any):
+ """JSON-safe: NaN/Inf -> None; rekurencyjnie czyści struktury."""
+ if isinstance(obj, float):
+ return None if (math.isnan(obj) or math.isinf(obj)) else obj
+ if isinstance(obj, (int, str)) or obj is None:
+ return obj
+ if isinstance(obj, dict):
+ return {k: _to_native(v) for k, v in obj.items()}
+ if isinstance(obj, (list, tuple)):
+ return [_to_native(x) for x in obj]
+ try:
+ return float(obj)
+ except Exception:
+ return str(obj)
+
+def save_json(path: str, data: Any) -> None:
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(_to_native(data), f, ensure_ascii=False, indent=2, allow_nan=False)
+
+class Portfolio:
+ """
+ LONG open: cash -= qty*price ; close: cash += qty*price ; PnL = (px - entry)*qty
+ SHORT open (stock): cash += qty*price ; close: cash -= qty*price ; PnL = (entry - px)*qty
+ SHORT open (margin): cash bez zmian ; close: cash += PnL ; PnL = (entry - px)*qty
+
+ total_value:
+ - stock -> cash + positions_net (wartość likwidacyjna)
+ - margin -> start_capital + realized_pnl + unrealized_pnl
+ """
+ def __init__(self, capital: float):
+ self.cash = float(capital)
+ self.start_capital = float(capital)
+ self.positions: Dict[str, Dict] = {}
+ self.history: List[Dict] = []
+ self.trade_log: List[Dict] = []
+ self.realized_pnl: float = 0.0
+ self.last_prices: Dict[str, float] = {} # ostatnie znane ceny
+
+ # ---------- helpers ----------
+ def _get_params(self, ticker: str):
+ o = SYMBOL_OVERRIDES.get(ticker, {})
+ sl = float(o.get("SL_PCT", SL_PCT))
+ tp = float(o.get("TP_PCT", TP_PCT))
+ tr = float(o.get("TRAIL_PCT", TRAIL_PCT))
+ return sl, tp, tr
+
+ def _init_risk(self, ticker: str, entry: float, side: int, atr: Optional[float] = None):
+ """Zwraca: stop, take, trail_best, trail_stop, one_r."""
+ sl_pct, tp_pct, trail_pct = self._get_params(ticker)
+ use_atr = USE_ATR and atr is not None and isinstance(atr, (int, float)) and atr > 0.0
+
+ if use_atr:
+ sl_dist = SL_ATR_MULT * float(atr)
+ tp_dist = TP_ATR_MULT * float(atr)
+ stop = entry - sl_dist if side == 1 else entry + sl_dist
+ take = entry + tp_dist if side == 1 else entry - tp_dist
+ one_r = sl_dist
+ else:
+ if side == 1:
+ stop = entry * (1.0 - sl_pct); take = entry * (1.0 + tp_pct)
+ else:
+ stop = entry * (1.0 + sl_pct); take = entry * (1.0 - tp_pct)
+ one_r = abs(entry - stop)
+
+ trail_best = entry
+ trail_stop = stop
+ return stop, take, trail_best, trail_stop, max(one_r, 1e-8)
+
+ def _update_trailing(self, ticker: str, pos: Dict, price: float):
+ """Breakeven po 1R + trailing procentowy."""
+ _, _, trail_pct = self._get_params(ticker)
+
+ # BE po 1R
+ if pos["side"] == 1 and not pos.get("be_done", False):
+ if price >= pos["entry"] + pos["one_r"]:
+ pos["stop"] = max(pos["stop"], pos["entry"])
+ pos["be_done"] = True
+ elif pos["side"] == -1 and not pos.get("be_done", False):
+ if price <= pos["entry"] - pos["one_r"]:
+ pos["stop"] = min(pos["stop"], pos["entry"])
+ pos["be_done"] = True
+
+ # trailing
+ if pos["side"] == 1:
+ if price > pos["trail_best"]:
+ pos["trail_best"] = price
+ pos["trail_stop"] = pos["trail_best"] * (1.0 - trail_pct)
+ else:
+ if price < pos["trail_best"]:
+ pos["trail_best"] = price
+ pos["trail_stop"] = pos["trail_best"] * (1.0 + trail_pct)
+
+ def _positions_values(self, prices: Optional[Dict[str, float]] = None) -> Dict[str, float]:
+ """Zwraca Σ|qty*px| oraz Σqty*px*side. Fallback ceny: prices -> self.last_prices -> entry."""
+ gross = 0.0
+ net = 0.0
+ for t, p in self.positions.items():
+ px = None
+ if prices is not None:
+ px = prices.get(t)
+ if px is None or (isinstance(px, float) and math.isnan(px)):
+ px = self.last_prices.get(t)
+ if px is None or (isinstance(px, float) and math.isnan(px)):
+ px = p["entry"]
+ gross += abs(p["qty"] * px)
+ net += p["qty"] * px * p["side"]
+ return {"positions_gross": gross, "positions_net": net}
+
+ def unrealized_pnl(self, prices: Dict[str, float]) -> float:
+ """Σ side * (px - entry) * qty."""
+ upnl = 0.0
+ for t, p in self.positions.items():
+ px = prices.get(t, self.last_prices.get(t, p["entry"]))
+ if px is None or (isinstance(px, float) and math.isnan(px)): px = p["entry"]
+ upnl += p["side"] * (px - p["entry"]) * p["qty"]
+ return upnl
+
+ def equity_from_start(self, prices: Dict[str, float]) -> float:
+ return self.start_capital + self.realized_pnl + self.unrealized_pnl(prices)
+
+ # ---------- zamknięcia / log ----------
+ def _close_position(self, ticker: str, price: float, reason: str):
+ pos = self.positions.get(ticker)
+ if not pos: return
+ qty, entry, side = pos["qty"], pos["entry"], pos["side"]
+
+ if side == 1:
+ self.cash += qty * price
+ pnl_abs = (price - entry) * qty
+ else:
+ pnl_abs = (entry - price) * qty
+ if ACCOUNTING_MODE == "stock":
+ self.cash -= qty * price
+ else:
+ self.cash += pnl_abs # margin: tylko PnL wpływa na cash
+
+ denom = max(qty * entry, 1e-12)
+ pnl_pct = pnl_abs / denom
+ self.realized_pnl += pnl_abs
+
+ self._log_trade("SELL", ticker, price, qty, side, pnl_abs, pnl_pct, reason=reason)
+ del self.positions[ticker]
+
+ def _log_trade(self, action: str, ticker: str, price: float, qty: float, side: int,
+ pnl_abs: float = 0.0, pnl_pct: float = 0.0, reason: Optional[str] = None):
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
+ if action == "BUY":
+ print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={'LONG' if side==1 else 'SHORT'}, cash={self.cash:.2f}")
+ else:
+ r = f", reason={reason}" if reason else ""
+ print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), cash={self.cash:.2f}{r}")
+ self.trade_log.append({
+ "time": ts, "action": action, "ticker": ticker, "price": float(price),
+ "qty": float(qty), "side": int(side),
+ "pnl_abs": float(pnl_abs), "pnl_pct": float(pnl_pct),
+ "realized_pnl_cum": float(self.realized_pnl), "cash_after": float(self.cash),
+ "reason": reason or ""
+ })
+
+ # ---------- główna aktualizacja ----------
+ def on_signals(self, sigs: List[dict]):
+ # normalizacja
+ clean: List[Dict[str, Any]] = []
+ for s in sigs:
+ if isinstance(s, dict):
+ px = s.get("price")
+ if s.get("error") is None and px is not None and not math.isnan(px):
+ clean.append(s)
+ else:
+ if getattr(s, "error", None) is None and not math.isnan(getattr(s, "price", float("nan"))):
+ clean.append({
+ "ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time,
+ "atr": getattr(s, "atr", None)
+ })
+
+ # snapshot, gdy brak świeżych danych
+ if not clean:
+ vals = self._positions_values(None)
+ prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()}
+ if ACCOUNTING_MODE == "stock":
+ account_value = self.cash + vals["positions_net"]
+ else:
+ account_value = self.equity_from_start(prices_map)
+ unreal = self.unrealized_pnl(prices_map)
+ self.history.append({
+ "time": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "cash": float(self.cash),
+ "positions_value": float(vals["positions_gross"]),
+ "positions_net": float(vals["positions_net"]),
+ "total_value": float(account_value),
+ "equity_from_start": float(self.start_capital + self.realized_pnl + unreal),
+ "unrealized_pnl": float(unreal),
+ "realized_pnl_cum": float(self.realized_pnl),
+ "open_positions": int(len(self.positions))
+ })
+ save_json("portfolio_history.json", self.history)
+ return
+
+ # mapy cen
+ prices = {s["ticker"]: float(s["price"]) for s in clean}
+ self.last_prices.update(prices)
+
+ # 1) risk exits (SL/TP/TRAIL + BE)
+ to_close = []
+ for t, pos in list(self.positions.items()):
+ price = prices.get(t)
+ if price is None or math.isnan(price): continue
+ self._update_trailing(t, pos, price)
+ if pos["side"] == 1:
+ if price <= pos["stop"]: to_close.append((t, "SL"))
+ elif price >= pos["take"]: to_close.append((t, "TP"))
+ elif price <= pos.get("trail_stop", -float("inf")): to_close.append((t, "TRAIL"))
+ else:
+ if price >= pos["stop"]: to_close.append((t, "SL"))
+ elif price <= pos["take"]: to_close.append((t, "TP"))
+ elif price >= pos.get("trail_stop", float("inf")): to_close.append((t, "TRAIL"))
+ for t, reason in to_close:
+ price = prices.get(t)
+ if price is not None and not math.isnan(price) and t in self.positions:
+ self._close_position(t, price, reason=reason)
+
+ # 2) zamknięcie na przeciwny/HOLD
+ for s in clean:
+ t, sig = s["ticker"], int(s["signal"])
+ price = float(s["price"])
+ if t in self.positions:
+ pos = self.positions[t]
+ if sig == 0 or sig != pos["side"]:
+ self._close_position(t, price, reason="SIGNAL")
+
+ # 3) otwarcia – ATR sizing + limity
+ candidates = [s for s in clean if s["ticker"] not in self.positions and int(s["signal"]) != 0]
+ if MAX_NEW_POSITIONS_PER_CYCLE and MAX_NEW_POSITIONS_PER_CYCLE > 0:
+ candidates = candidates[:MAX_NEW_POSITIONS_PER_CYCLE]
+
+ for s in candidates:
+ t, sig, price = s["ticker"], int(s["signal"]), float(s["price"])
+ atr = s.get("atr", None)
+ if price <= 0: continue
+
+ stop, take, trail_best, trail_stop, one_r = self._init_risk(t, price, sig, atr)
+
+ # equity teraz (dla sizingu ryzyka)
+ if ACCOUNTING_MODE == "margin":
+ equity_now = self.start_capital + self.realized_pnl + self.unrealized_pnl(self.last_prices)
+ else:
+ vals_tmp = self._positions_values(self.last_prices)
+ equity_now = self.cash + vals_tmp["positions_net"]
+
+ risk_amount = max(1e-6, equity_now * RISK_FRACTION)
+ qty_risk = risk_amount / max(one_r, 1e-8)
+
+ # limit gotówki dla LONG
+ per_trade_cash = max(MIN_TRADE_CASH, self.cash * ALLOC_FRACTION)
+ qty_cash = per_trade_cash / max(price, 1e-12) if sig == 1 else float("inf")
+
+ qty = max(0.0, min(qty_risk, qty_cash))
+ if qty <= 0: continue
+
+ # księgowanie gotówki przy otwarciu
+ if sig == 1:
+ cost = qty * price
+ if self.cash < cost: continue
+ self.cash -= cost
+ else:
+ if ACCOUNTING_MODE == "stock":
+ self.cash += qty * price
+ # margin: brak zmiany cash przy short open
+
+ self.positions[t] = {
+ "qty": qty, "entry": price, "side": sig,
+ "stop": stop, "take": take,
+ "trail_best": trail_best, "trail_stop": trail_stop,
+ "one_r": one_r, "be_done": False
+ }
+ self._log_trade("BUY" if sig == 1 else "SELL", t, price, qty, sig)
+
+ # 4) snapshot na bieżących/ostatnich cenach
+ vals = self._positions_values(self.last_prices)
+ prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()}
+ if ACCOUNTING_MODE == "stock":
+ account_value = self.cash + vals["positions_net"]
+ else:
+ account_value = self.equity_from_start(prices_map)
+ unreal = self.unrealized_pnl(prices_map)
+
+ self.history.append({
+ "time": clean[0]["time"],
+ "cash": float(self.cash),
+ "positions_value": float(vals["positions_gross"]),
+ "positions_net": float(vals["positions_net"]),
+ "total_value": float(account_value),
+ "equity_from_start": float(self.start_capital + self.realized_pnl + unreal),
+ "unrealized_pnl": float(unreal),
+ "realized_pnl_cum": float(self.realized_pnl),
+ "open_positions": int(len(self.positions))
+ })
+
+ # 5) zapisy
+ save_json("trade_log.json", self.trade_log)
+ save_json("portfolio_history.json", self.history)
+ save_json("positions.json", [{"ticker": t, **p} for t, p in self.positions.items()])
diff --git a/new/portfolio_history.json b/new/portfolio_history.json
new file mode 100644
index 0000000..57abe6e
--- /dev/null
+++ b/new/portfolio_history.json
@@ -0,0 +1,79 @@
+[
+ {
+ "time": "2025-08-15 10:11:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 0.0,
+ "positions_net": 0.0,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 0
+ },
+ {
+ "time": "2025-08-15 10:12:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 0.0,
+ "positions_net": 0.0,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 0
+ },
+ {
+ "time": "2025-08-15 10:13:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 0.0,
+ "positions_net": 0.0,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 0
+ },
+ {
+ "time": "2025-08-15 10:14:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 0.0,
+ "positions_net": 0.0,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 0
+ },
+ {
+ "time": "2025-08-15 10:15:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 0.0,
+ "positions_net": 0.0,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 0
+ },
+ {
+ "time": "2025-08-15 10:16:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 5999.999999999975,
+ "positions_net": -5999.999999999975,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 2
+ },
+ {
+ "time": "2025-08-15 10:17:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 5999.999999999975,
+ "positions_net": -5999.999999999975,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 2
+ }
+]
\ No newline at end of file
diff --git a/new/positions.json b/new/positions.json
new file mode 100644
index 0000000..01abac4
--- /dev/null
+++ b/new/positions.json
@@ -0,0 +1,26 @@
+[
+ {
+ "ticker": "OP-USD",
+ "qty": 3973.896468582607,
+ "entry": 0.7549265623092651,
+ "side": -1,
+ "stop": 0.7624758279323578,
+ "take": 0.7398280310630798,
+ "trail_best": 0.7549265623092651,
+ "trail_stop": 0.7624758279323578,
+ "one_r": 0.00754926562309266,
+ "be_done": false
+ },
+ {
+ "ticker": "NEAR-USD",
+ "qty": 1076.5240904642392,
+ "entry": 2.7867467403411865,
+ "side": -1,
+ "stop": 2.8146142077445986,
+ "take": 2.731011805534363,
+ "trail_best": 2.7867467403411865,
+ "trail_stop": 2.8146142077445986,
+ "one_r": 0.02786746740341206,
+ "be_done": false
+ }
+]
\ No newline at end of file
diff --git a/new/signals_scan.json b/new/signals_scan.json
new file mode 100644
index 0000000..23a5472
--- /dev/null
+++ b/new/signals_scan.json
@@ -0,0 +1,344 @@
+[
+ {
+ "ticker": "GBPUSD=X",
+ "time": "2025-08-15 10:17:00+00:00",
+ "price": 1.3561896085739136,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURUSD=X",
+ "time": "2025-08-15 10:17:00+00:00",
+ "price": 1.1691803932189941,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "CHFJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 182.3179931640625,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "DOGE-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 0.2317201942205429,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "OP-USD",
+ "time": "2025-08-15 10:11:00+00:00",
+ "price": 0.7549265623092651,
+ "signal": -1,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "BTC-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 118967.421875,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "NZDUSD=X",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.5927330851554871,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURAUD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.7958300113677979,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "USDCHF=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.8054800033569336,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "SOL-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 195.40365600585938,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "USDJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 146.86399841308594,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "GBPJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 199.14500427246094,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "XLM-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.42842021584510803,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURGBP=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.8619400262832642,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "LTC-USD",
+ "time": "2025-08-15 10:14:00+00:00",
+ "price": 121.15275573730469,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "USDCAD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.3791099786758423,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "BNB-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 844.4680786132812,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ETH-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 4635.28955078125,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ETC-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 22.40416717529297,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "CADJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 106.49600219726562,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "TRX-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.3588825762271881,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 171.65899658203125,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "TON-USD",
+ "time": "2025-08-15 10:11:00+00:00",
+ "price": 0.01708882860839367,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ADA-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.951282799243927,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "NZDJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 87.01699829101562,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "AUDJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 95.58399963378906,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURCAD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.611799955368042,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "BCH-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 596.3115844726562,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "DOT-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 4.005911827087402,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ATOM-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 4.517627239227295,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "AUDNZD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.0983599424362183,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "GC=F",
+ "time": "2025-08-15 10:08:00+00:00",
+ "price": 3386.10009765625,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "NEAR-USD",
+ "time": "2025-08-15 10:11:00+00:00",
+ "price": 2.7867467403411865,
+ "signal": -1,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "GBPCAD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.8698300123214722,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "XRP-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 3.111118793487549,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURCHF=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.9409899711608887,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "LINK-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 22.371389389038086,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "AUDUSD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.6509992480278015,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ }
+]
\ No newline at end of file
diff --git a/new/snapshot.json b/new/snapshot.json
new file mode 100644
index 0000000..f97cac6
--- /dev/null
+++ b/new/snapshot.json
@@ -0,0 +1,385 @@
+{
+ "time": "2025-08-15 12:19:02",
+ "last_history": {
+ "time": "2025-08-15 10:17:00+00:00",
+ "cash": 10000.0,
+ "positions_value": 5999.999999999975,
+ "positions_net": -5999.999999999975,
+ "total_value": 10000.0,
+ "equity_from_start": 10000.0,
+ "unrealized_pnl": 0.0,
+ "realized_pnl_cum": 0.0,
+ "open_positions": 2
+ },
+ "positions": [
+ {
+ "ticker": "OP-USD",
+ "qty": 3973.896468582607,
+ "entry": 0.7549265623092651,
+ "side": -1,
+ "stop": 0.7624758279323578,
+ "take": 0.7398280310630798,
+ "trail_best": 0.7549265623092651,
+ "trail_stop": 0.7624758279323578,
+ "one_r": 0.00754926562309266,
+ "be_done": false
+ },
+ {
+ "ticker": "NEAR-USD",
+ "qty": 1076.5240904642392,
+ "entry": 2.7867467403411865,
+ "side": -1,
+ "stop": 2.8146142077445986,
+ "take": 2.731011805534363,
+ "trail_best": 2.7867467403411865,
+ "trail_stop": 2.8146142077445986,
+ "one_r": 0.02786746740341206,
+ "be_done": false
+ }
+ ],
+ "signals": [
+ {
+ "ticker": "GBPUSD=X",
+ "time": "2025-08-15 10:17:00+00:00",
+ "price": 1.3561896085739136,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURUSD=X",
+ "time": "2025-08-15 10:17:00+00:00",
+ "price": 1.1691803932189941,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "CHFJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 182.3179931640625,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "DOGE-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 0.2317201942205429,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "OP-USD",
+ "time": "2025-08-15 10:11:00+00:00",
+ "price": 0.7549265623092651,
+ "signal": -1,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "BTC-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 118967.421875,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "NZDUSD=X",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.5927330851554871,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURAUD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.7958300113677979,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "USDCHF=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.8054800033569336,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "SOL-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 195.40365600585938,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "USDJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 146.86399841308594,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "GBPJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 199.14500427246094,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "XLM-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.42842021584510803,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURGBP=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.8619400262832642,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "LTC-USD",
+ "time": "2025-08-15 10:14:00+00:00",
+ "price": 121.15275573730469,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "USDCAD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.3791099786758423,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "BNB-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 844.4680786132812,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ETH-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 4635.28955078125,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ETC-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 22.40416717529297,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "CADJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 106.49600219726562,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "TRX-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.3588825762271881,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 171.65899658203125,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "TON-USD",
+ "time": "2025-08-15 10:11:00+00:00",
+ "price": 0.01708882860839367,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ADA-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 0.951282799243927,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "NZDJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 87.01699829101562,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "AUDJPY=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 95.58399963378906,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURCAD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.611799955368042,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "BCH-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 596.3115844726562,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "DOT-USD",
+ "time": "2025-08-15 10:15:00+00:00",
+ "price": 4.005911827087402,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "ATOM-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 4.517627239227295,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "AUDNZD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.0983599424362183,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "GC=F",
+ "time": "2025-08-15 10:08:00+00:00",
+ "price": 3386.10009765625,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "NEAR-USD",
+ "time": "2025-08-15 10:11:00+00:00",
+ "price": 2.7867467403411865,
+ "signal": -1,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "GBPCAD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 1.8698300123214722,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "XRP-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 3.111118793487549,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "EURCHF=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.9409899711608887,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "LINK-USD",
+ "time": "2025-08-15 10:16:00+00:00",
+ "price": 22.371389389038086,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ },
+ {
+ "ticker": "AUDUSD=X",
+ "time": "2025-08-15 10:18:00+00:00",
+ "price": 0.6509992480278015,
+ "signal": 0,
+ "period": "7d",
+ "interval": "1m",
+ "error": null
+ }
+ ],
+ "capital_start": 10000.0
+}
\ No newline at end of file
diff --git a/new/static/app.js b/new/static/app.js
new file mode 100644
index 0000000..26fc734
--- /dev/null
+++ b/new/static/app.js
@@ -0,0 +1,221 @@
+// static/app.js
+const fmt2 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(2));
+const fmt6 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(6));
+const fmtPct= (x) => (x == null || isNaN(x) ? "—" : (Number(x) * 100).toFixed(2) + "%");
+
+async function getJSON(url) {
+ const r = await fetch(url, { cache: "no-store" });
+ if (!r.ok) throw new Error(`${url}: ${r.status}`);
+ return r.json();
+}
+
+// prosty wykres linii na canvas (bez bibliotek)
+function drawLineChart(canvas, points) {
+ const ctx = canvas.getContext("2d");
+ const pad = 32;
+ const w = canvas.width, h = canvas.height;
+ ctx.clearRect(0, 0, w, h);
+
+ if (!points || points.length === 0) {
+ ctx.fillStyle = "#9aa3b2";
+ ctx.fillText("Brak danych", 10, 20);
+ return;
+ }
+
+ const n = points.length;
+ const ys = points.map(p => p.y);
+ const minY = Math.min(...ys), maxY = Math.max(...ys);
+ const yLo = minY === maxY ? minY - 1 : minY;
+ const yHi = minY === maxY ? maxY + 1 : maxY;
+
+ const x0 = pad, y0 = h - pad, x1 = w - pad, y1 = pad;
+ const xScale = (i) => x0 + (i / (n - 1)) * (x1 - x0);
+ const yScale = (y) => y0 - ((y - yLo) / (yHi - yLo)) * (y0 - y1);
+
+ // osie
+ ctx.strokeStyle = "#242a36";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(x0, y0); ctx.lineTo(x1, y0);
+ ctx.moveTo(x0, y0); ctx.lineTo(x0, y1);
+ ctx.stroke();
+
+ // siatka
+ ctx.strokeStyle = "#1b2130";
+ [0.25, 0.5, 0.75].forEach(f => {
+ const yy = y0 - (y0 - y1) * f;
+ ctx.beginPath();
+ ctx.moveTo(x0, yy); ctx.lineTo(x1, yy);
+ ctx.stroke();
+ });
+
+ // podpisy min/max
+ ctx.fillStyle = "#9aa3b2";
+ ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial";
+ ctx.fillText(fmt2(yHi), 6, y1 + 10);
+ ctx.fillText(fmt2(yLo), 6, y0 - 2);
+
+ // linia
+ ctx.strokeStyle = "#4da3ff";
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(xScale(0), yScale(ys[0]));
+ for (let i = 1; i < n; i++) ctx.lineTo(xScale(i), yScale(ys[i]));
+ ctx.stroke();
+
+ // kropka na końcu
+ const lastX = xScale(n - 1), lastY = yScale(ys[n - 1]);
+ ctx.fillStyle = "#e7e9ee";
+ ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill();
+ ctx.fillText(fmt2(ys[n - 1]), lastX + 6, lastY - 6);
+}
+
+async function loadAll() {
+ try {
+ const [snap, hist, pos, trades] = await Promise.all([
+ getJSON("/api/snapshot"),
+ getJSON("/api/history"),
+ getJSON("/api/positions"),
+ getJSON("/api/trades"),
+ ]);
+
+ // czas
+ document.getElementById("last-update").textContent =
+ "Ostatnia aktualizacja: " + (snap?.last_history?.time || "—");
+
+ // ===== ostatni wiersz historii =====
+ const last = Array.isArray(hist) && hist.length ? hist[hist.length - 1] : null;
+
+ const cashVal = Number(last?.cash ?? 0);
+ const totalVal = Number(
+ last?.total_value ??
+ (Number(last?.cash ?? 0) + Number(last?.positions_net ?? 0)) // fallback dla starszych logów
+ );
+
+ // KARTY
+ document.getElementById("cash").textContent = fmt2(cashVal);
+ document.getElementById("total-value").textContent = fmt2(totalVal);
+
+ // mapa ostatnich cen do liczenia zysku (unrealized)
+ const lastPrice = new Map();
+ (snap?.signals || []).forEach(s => {
+ const px = Number(s.price);
+ if (!isNaN(px)) lastPrice.set(s.ticker, px);
+ });
+
+ // Zysk = suma niezrealizowanych PnL na otwartych pozycjach
+ let unrealPnL = 0;
+ (pos || []).forEach(p => {
+ const price = Number(lastPrice.get(p.ticker));
+ const entry = Number(p.entry);
+ const qty = Number(p.qty);
+ const side = Number(p.side);
+ if (isNaN(price) || isNaN(entry) || isNaN(qty) || isNaN(side)) return;
+ unrealPnL += side === 1 ? (price - entry) * qty : (entry - price) * qty;
+ });
+ const unrlEl = document.getElementById("unrealized");
+ unrlEl.textContent = fmt2(unrealPnL);
+ unrlEl.classList.remove("pnl-positive", "pnl-negative");
+ if (unrealPnL > 0) unrlEl.classList.add("pnl-positive");
+ else if (unrealPnL < 0) unrlEl.classList.add("pnl-negative");
+
+ // liczba otwartych pozycji
+ document.getElementById("open-pos").textContent =
+ Number(last?.open_positions ?? (pos?.length ?? 0));
+
+ // ===== WYKRES WARTOŚCI KONTA (TOTAL) =====
+ const totalCanvas = document.getElementById("totalChart");
+ const totalPoints = (hist || [])
+ .map((row, i) => {
+ const v = (row.total_value != null)
+ ? Number(row.total_value)
+ : Number(row.cash ?? 0) + Number(row.positions_net ?? 0);
+ return { x: i, y: v };
+ })
+ .filter(p => !isNaN(p.y))
+ .slice(-500);
+ drawLineChart(totalCanvas, totalPoints);
+
+ // ===== WYKRES GOTÓWKI (CASH) =====
+ const cashCanvas = document.getElementById("cashChart");
+ const cashPoints = (hist || [])
+ .map((row, i) => ({ x: i, y: Number(row.cash ?? NaN) }))
+ .filter(p => !isNaN(p.y))
+ .slice(-500);
+ drawLineChart(cashCanvas, cashPoints);
+
+ // ===== POZYCJE =====
+ const posBody = document.querySelector("#positions-table tbody");
+ posBody.innerHTML = "";
+ (pos || []).forEach((p) => {
+ const price = Number(lastPrice.get(p.ticker));
+ const entry = Number(p.entry);
+ const qty = Number(p.qty);
+ const side = Number(p.side);
+
+ let upnl = NaN, upct = NaN;
+ if (!isNaN(price) && !isNaN(entry) && !isNaN(qty) && !isNaN(side)) {
+ upnl = side === 1 ? (price - entry) * qty : (entry - price) * qty;
+ const denom = Math.max(qty * entry, 1e-12);
+ upct = upnl / denom;
+ }
+ const pnlClass = upnl > 0 ? "pnl-positive" : (upnl < 0 ? "pnl-negative" : "");
+
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+
${p.ticker} |
+ ${fmt6(qty)} |
+ ${fmt6(entry)} |
+ ${side === 1 ? "LONG" : "SHORT"} |
+ ${fmt6(price)} |
+ ${fmt2(upnl)} |
+ ${fmtPct(upct)} |
+ `;
+ posBody.appendChild(tr);
+ });
+
+ // ===== SYGNAŁY =====
+ const sigBody = document.querySelector("#signals-table tbody");
+ sigBody.innerHTML = "";
+ const signals = (snap?.signals || []).slice().sort((a,b)=>a.ticker.localeCompare(b.ticker));
+ signals.forEach((s) => {
+ const sigTxt = s.signal === 1 ? "BUY" : (s.signal === -1 ? "SELL" : "HOLD");
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${s.ticker} |
+ ${s.time} |
+ ${fmt6(s.price)} |
+ ${sigTxt} |
+ ${s.interval} |
+ `;
+ sigBody.appendChild(tr);
+ });
+
+ // ===== TRANSAKCJE =====
+ const tradesBody = document.querySelector("#trades-table tbody");
+ tradesBody.innerHTML = "";
+ (trades || []).slice(-50).reverse().forEach((t) => {
+ const pnlClass = t.pnl_abs > 0 ? "pnl-positive" : (t.pnl_abs < 0 ? "pnl-negative" : "");
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${t.time} |
+ ${t.action} |
+ ${t.ticker} |
+ ${fmt6(t.price)} |
+ ${fmt6(t.qty)} |
+ ${fmt2(t.pnl_abs)} |
+ ${fmtPct(t.pnl_pct)} |
+ ${fmt2(t.cash_after)} |
+ `;
+ tradesBody.appendChild(tr);
+ });
+
+ } catch (e) {
+ console.error("loadAll error:", e);
+ document.getElementById("last-update").textContent = "Błąd ładowania danych";
+ }
+}
+
+document.getElementById("refresh-btn").addEventListener("click", loadAll);
+loadAll();
+setInterval(loadAll, 1000);
diff --git a/new/static/styles.css b/new/static/styles.css
new file mode 100644
index 0000000..a951116
--- /dev/null
+++ b/new/static/styles.css
@@ -0,0 +1,28 @@
+/* static/styles.css */
+:root { --bg:#0f1115; --panel:#171a21; --text:#e7e9ee; --muted:#9aa3b2; --buy:#1fbf75; --sell:#ff4d4f; }
+* { box-sizing: border-box; }
+body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; }
+header { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; background:var(--panel); border-bottom:1px solid #242a36; }
+h1 { margin:0; font-size:18px; }
+.meta { display:flex; gap:12px; align-items:center; color:var(--muted); }
+button { background:#2a3242; color:var(--text); border:1px solid #2f3749; padding:6px 10px; border-radius:6px; cursor:pointer; }
+main { padding:16px 20px; }
+.cards { display:grid; grid-template-columns:repeat(4, minmax(160px, 1fr)); gap:12px; margin-bottom:14px; }
+.card { background:var(--panel); border:1px solid #242a36; border-radius:10px; padding:12px; }
+.card-title { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px; }
+.card-value { font-size:20px; font-weight:600; }
+section { margin-top:18px; }
+table { width:100%; border-collapse:separate; border-spacing:0; background:var(--panel); border:1px solid #242a36; border-radius:10px; overflow:hidden; }
+thead th { text-align:left; color:var(--muted); font-weight:600; padding:10px 12px; background:#141821; }
+tbody td { padding:9px 12px; border-top:1px solid #202637; }
+td.BUY, .BUY { color:var(--buy); font-weight:600; }
+td.SELL, .SELL { color:var(--sell); font-weight:600; }
+td.HOLD, .HOLD { color:#c8cbd2; }
+/* PnL kolory */
+.pnl-positive { color: var(--buy); font-weight: 600; }
+.pnl-negative { color: var(--sell); font-weight: 600; }
+.BUY { color: var(--buy); font-weight: 600; }
+.SELL { color: var(--sell); font-weight: 600; }
+.HOLD { color: #c8cbd2; }
+.chart-wrap { background: var(--panel); border:1px solid #242a36; border-radius:10px; padding:10px; margin-bottom:16px; }
+
diff --git a/new/strategies.py b/new/strategies.py
new file mode 100644
index 0000000..37cc4ae
--- /dev/null
+++ b/new/strategies.py
@@ -0,0 +1,137 @@
+# strategies.py
+from __future__ import annotations
+import numpy as np
+import pandas as pd
+
+# ===== PRESET =====
+BUY_TH = 0.55
+SELL_TH = -0.55
+REQUIRE_TREND_CONFLUENCE = True # EMA50 vs EMA200 musi się zgadzać z kierunkiem
+USE_ADX_IN_FILTER = True # używaj ADX w filtrze kierunku
+TREND_FILTER_ADX_MIN = 18.0
+MTF_CONFLICT_DAMP = 0.6 # konflikt 1m vs 15m – osłab wynik
+
+# ===== FILTRY DODATKOWE =====
+SESSION_FILTER = True
+SESS_UTC_START = 6 # handluj tylko 06:00–21:00 UTC (LON+NY)
+SESS_UTC_END = 21
+
+VOL_FILTER = True
+MIN_ATR_PCT = 0.0005 # min ATR/price (0.05%)
+MAX_ATR_PCT = 0.02 # max ATR/price (2%) – unikaj paniki
+OVEREXT_COEF = 1.2 # nie wchodź, jeśli |close-EMA20| > 1.2*ATR
+
+def _resample_ohlc(df: pd.DataFrame, rule: str = "15T") -> pd.DataFrame:
+ o = {"open":"first","high":"max","low":"min","close":"last"}
+ return df[["open","high","low","close"]].resample(rule).agg(o).dropna(how="any")
+
+def _safe(df: pd.DataFrame, col: str, default=None):
+ return float(df[col].iloc[-1]) if col in df.columns else (float(default) if default is not None else None)
+
+def _ema(series: pd.Series, span: int) -> float:
+ return float(series.ewm(span=span, adjust=False).mean().iloc[-1])
+
+def _atr_pct(df: pd.DataFrame, n: int = 14) -> float:
+ if "atr" in df.columns:
+ atr = float(df["atr"].iloc[-1])
+ else:
+ h, l, c = df["high"], df["low"], df["close"]
+ prev_c = c.shift(1)
+ tr = pd.concat([h - l, (h - prev_c).abs(), (l - prev_c).abs()], axis=1).max(axis=1)
+ atr = float(tr.ewm(span=n, adjust=False).mean().iloc[-1])
+ price = float(df["close"].iloc[-1])
+ return 0.0 if price <= 0 else atr / price
+
+def _in_session(df: pd.DataFrame) -> bool:
+ ts = pd.Timestamp(df.index[-1])
+ try:
+ hour = ts.tz_convert("UTC").hour if ts.tzinfo else ts.hour
+ except Exception:
+ hour = ts.hour
+ return SESS_UTC_START <= hour <= SESS_UTC_END
+
+def _trend_score_1m(df: pd.DataFrame) -> float:
+ s = 0.0
+ ema20 = _safe(df, "ema20", _ema(df["close"], 20))
+ ema50 = _safe(df, "ema50", _ema(df["close"], 50))
+ s += 0.5 if ema20 > ema50 else -0.5
+
+ macd = _safe(df, "macd", _ema(df["close"],12) - _ema(df["close"],26))
+ macd_sig = _safe(df, "macd_sig", macd)
+ s += 0.3 if macd > macd_sig else -0.3
+
+ adx = _safe(df, "adx", None)
+ if adx is not None:
+ if adx >= 18: s += 0.2
+ elif adx <= 12: s -= 0.1
+
+ c = float(df["close"].iloc[-1])
+ st = _safe(df, "supertrend", c)
+ s += 0.2 if c > st else -0.2
+
+ up = _safe(df, "bb_up", c + 1e9); dn = _safe(df, "bb_dn", c - 1e9)
+ if c > up: s += 0.2
+ if c < dn: s -= 0.2
+
+ rsi = _safe(df, "rsi14", 50.0)
+ if rsi >= 70: s -= 0.2
+ if rsi <= 30: s += 0.2
+ return s
+
+def _trend_score_15m(df_1m: pd.DataFrame) -> float:
+ try:
+ htf = _resample_ohlc(df_1m, "15T")
+ if len(htf) < 30: return 0.0
+ close = htf["close"]
+ ema20 = close.ewm(span=20, adjust=False).mean().iloc[-1]
+ ema50 = close.ewm(span=50, adjust=False).mean().iloc[-1]
+ macd_line = close.ewm(span=12, adjust=False).mean().iloc[-1] - close.ewm(span=26, adjust=False).mean().iloc[-1]
+ macd_sig = pd.Series(close).ewm(span=9, adjust=False).mean().iloc[-1]
+ s = (0.6 if ema20 > ema50 else -0.6) + (0.4 if macd_line > macd_sig else -0.4)
+ return float(s)
+ except Exception:
+ return 0.0
+
+def _hysteresis_map(score: float) -> int:
+ if score >= BUY_TH: return 1
+ if score <= SELL_TH: return -1
+ return 0
+
+def get_signal(df: pd.DataFrame) -> int:
+ if len(df) < 200:
+ return 0
+
+ # Filtry ex-ante
+ if SESSION_FILTER and not _in_session(df):
+ return 0
+ atrp = _atr_pct(df)
+ if VOL_FILTER and not (MIN_ATR_PCT <= atrp <= MAX_ATR_PCT):
+ return 0
+
+ s1 = _trend_score_1m(df)
+ s15 = _trend_score_15m(df)
+ score = 0.7 * s1 + 0.3 * s15
+ if (s1 > 0 and s15 < 0) or (s1 < 0 and s15 > 0):
+ score *= MTF_CONFLICT_DAMP
+ sig = _hysteresis_map(score)
+
+ # Nie handluj pod prąd + nie gonić świecy
+ if sig != 0 and REQUIRE_TREND_CONFLUENCE:
+ ema50 = _safe(df, "ema50", _ema(df["close"], 50))
+ ema200 = _safe(df, "ema200", _ema(df["close"], 200))
+ adx = _safe(df, "adx", None)
+ long_ok = (ema50 > ema200) and (not USE_ADX_IN_FILTER or adx is None or adx >= TREND_FILTER_ADX_MIN)
+ short_ok = (ema50 < ema200) and (not USE_ADX_IN_FILTER or adx is None or adx >= TREND_FILTER_ADX_MIN)
+ if sig == 1 and not long_ok: sig = 0
+ if sig == -1 and not short_ok: sig = 0
+
+ # za daleko od EMA20 -> poczekaj na pullback
+ if sig != 0:
+ ema20 = _safe(df, "ema20", _ema(df["close"], 20))
+ c = float(df["close"].iloc[-1])
+ # przy braku atr w df liczymy atrp już powyżej
+ atr = atrp * c if c > 0 else 0.0
+ if atr > 0.0 and abs(c - ema20) > OVEREXT_COEF * atr:
+ sig = 0
+
+ return int(sig)
diff --git a/new/templates/index.html b/new/templates/index.html
new file mode 100644
index 0000000..da2a5a9
--- /dev/null
+++ b/new/templates/index.html
@@ -0,0 +1,114 @@
+
+
+
+
+
+ Trading Dashboard
+
+
+
+
+
+
+
+
+
+
+
Wartość konta (total)
+
—
+
+
+
Zysk (otwarte pozycje)
+
—
+
+
+
+
+
+
+ Wartość konta (total) — wykres
+
+
+
+
+
+
+
+ Gotówka — wykres
+
+
+
+
+
+
+
+ Otwarte pozycje
+
+
+
+ | Ticker |
+ Qty |
+ Entry |
+ Side |
+ Last price |
+ Unreal. PnL |
+ Unreal. PnL % |
+
+
+
+
+
+
+
+
+ Ostatnie sygnały
+
+
+
+ | Ticker |
+ Time |
+ Price |
+ Signal |
+ Interval |
+
+
+
+
+
+
+
+
+ Transakcje (ostatnie 50)
+
+
+
+ | Time |
+ Action |
+ Ticker |
+ Price |
+ Qty |
+ PnL |
+ PnL % |
+ Cash po |
+
+
+
+
+
+
+
+
+
+
diff --git a/new/trade_log.json b/new/trade_log.json
new file mode 100644
index 0000000..2a1d04a
--- /dev/null
+++ b/new/trade_log.json
@@ -0,0 +1,28 @@
+[
+ {
+ "time": "2025-08-15 12:18:01",
+ "action": "SELL",
+ "ticker": "OP-USD",
+ "price": 0.7549265623092651,
+ "qty": 3973.896468582607,
+ "side": -1,
+ "pnl_abs": 0.0,
+ "pnl_pct": 0.0,
+ "realized_pnl_cum": 0.0,
+ "cash_after": 10000.0,
+ "reason": ""
+ },
+ {
+ "time": "2025-08-15 12:18:01",
+ "action": "SELL",
+ "ticker": "NEAR-USD",
+ "price": 2.7867467403411865,
+ "qty": 1076.5240904642392,
+ "side": -1,
+ "pnl_abs": 0.0,
+ "pnl_pct": 0.0,
+ "realized_pnl_cum": 0.0,
+ "cash_after": 10000.0,
+ "reason": ""
+ }
+]
\ No newline at end of file
diff --git a/new/trading_bot.py b/new/trading_bot.py
new file mode 100644
index 0000000..e6c52bd
--- /dev/null
+++ b/new/trading_bot.py
@@ -0,0 +1,157 @@
+# trading_bot_workers.py
+# pip install yfinance pandas numpy
+
+from __future__ import annotations
+import time, warnings, random, multiprocessing as mp, json
+from dataclasses import dataclass, asdict
+from typing import Dict, List, Tuple, Optional
+import pandas as pd
+import yfinance as yf
+
+from indicators import add_indicators
+from strategies import get_signal
+from portfolio import Portfolio
+
+warnings.filterwarnings("ignore", category=FutureWarning)
+
+# ========= KONFIG =========
+START_CAPITAL = 10_000.0
+MIN_BARS = 200
+SCAN_EVERY_S = 60
+YF_JITTER_S = (0.05, 0.25)
+PRIMARY = ("7d","1m") # preferowane: 1m/7d
+FALLBACKS = [("60d","5m"), ("60d","15m")] # fallbacki gdy 1m brak
+
+FOREX_20 = [
+ "EURUSD=X","USDJPY=X","GBPUSD=X","USDCHF=X","AUDUSD=X",
+ "USDCAD=X","NZDUSD=X","EURJPY=X","EURGBP=X","EURCHF=X",
+ "GBPJPY=X","CHFJPY=X","AUDJPY=X","AUDNZD=X","EURAUD=X",
+ "EURCAD=X","GBPCAD=X","CADJPY=X","NZDJPY=X","GC=F"
+]
+CRYPTO_20 = [
+ "BTC-USD","ETH-USD","BNB-USD","SOL-USD","XRP-USD","ADA-USD","DOGE-USD","TRX-USD","TON-USD","DOT-USD",
+ "LTC-USD","BCH-USD","ATOM-USD","LINK-USD","XLM-USD","ETC-USD","NEAR-USD","OP-USD"
+]
+ALL_TICKERS = FOREX_20 + CRYPTO_20
+
+# ========= Pobieranie z fallbackiem =========
+def yf_download_with_fallback(ticker: str) -> Tuple[pd.DataFrame, str, str]:
+ time.sleep(random.uniform(*YF_JITTER_S))
+ for period, interval in (PRIMARY, *FALLBACKS):
+ df = yf.download(ticker, period=period, interval=interval, auto_adjust=False, progress=False, threads=False)
+ if df is not None and not df.empty:
+ df = df.rename(columns=str.lower).dropna()
+ if not isinstance(df.index, pd.DatetimeIndex):
+ df.index = pd.to_datetime(df.index)
+ return df, period, interval
+ raise ValueError("No data in primary/fallback intervals")
+
+# ========= Worker: jeden instrument = jeden proces =========
+@dataclass
+class Signal:
+ ticker: str
+ time: str
+ price: float
+ signal: int
+ period: str
+ interval: str
+ error: Optional[str] = None
+
+def worker(ticker: str, out_q: mp.Queue, stop_evt: mp.Event, min_bars: int = MIN_BARS):
+ while not stop_evt.is_set():
+ try:
+ df, used_period, used_interval = yf_download_with_fallback(ticker)
+ if len(df) < min_bars:
+ raise ValueError(f"Too few bars: {len(df)}<{min_bars} at {used_interval}")
+ df = add_indicators(df, min_bars=min_bars)
+ sig = get_signal(df)
+ price = float(df["close"].iloc[-1])
+ ts = str(df.index[-1])
+ out_q.put(Signal(ticker, ts, price, sig, used_period, used_interval))
+ except Exception as e:
+ out_q.put(Signal(ticker, time.strftime("%Y-%m-%d %H:%M:%S"), float("nan"), 0, "NA", "NA", error=str(e)))
+ time.sleep(SCAN_EVERY_S)
+
+# ========= Pomocnicze: serializacja do JSON =========
+def _to_native(obj):
+ """Bezpieczny rzut na prymitywy JSON (float/int/str/bool/None)."""
+ if isinstance(obj, (float, int, str)) or obj is None:
+ return obj
+ if isinstance(obj, dict):
+ return {k: _to_native(v) for k, v in obj.items()}
+ if isinstance(obj, (list, tuple)):
+ return [_to_native(x) for x in obj]
+ if hasattr(obj, "__dataclass_fields__"): # dataclass
+ return _to_native(asdict(obj))
+ try:
+ return float(obj)
+ except Exception:
+ return str(obj)
+
+def save_json(path: str, data) -> None:
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(_to_native(data), f, ensure_ascii=False, indent=2)
+
+# ========= Master (koordynacja) =========
+def main():
+ mp.set_start_method("spawn", force=True)
+ out_q: mp.Queue = mp.Queue()
+ stop_evt: mp.Event = mp.Event()
+
+ # 1 instrument = 1 proces (daemon)
+ procs: List[mp.Process] = []
+ for t in ALL_TICKERS:
+ p = mp.Process(target=worker, args=(t, out_q, stop_evt), daemon=True)
+ p.start()
+ procs.append(p)
+
+ portfolio = Portfolio(START_CAPITAL)
+ last_dump = 0.0
+ signals_window: Dict[str, Signal] = {}
+
+ try:
+ while True:
+ # zbieramy sygnały przez okno ~1 minuty
+ deadline = time.time() + SCAN_EVERY_S
+ while time.time() < deadline:
+ try:
+ s: Signal = out_q.get(timeout=0.5)
+ signals_window[s.ticker] = s
+ except Exception:
+ pass
+
+ if signals_window:
+ sig_list = list(signals_window.values())
+ portfolio.on_signals(sig_list)
+
+ now = time.time()
+ if now - last_dump > SCAN_EVERY_S - 1:
+ # --- JSON zapisy ---
+ save_json("signals_scan.json", [asdict(s) for s in sig_list])
+ save_json("portfolio_history.json", portfolio.history)
+ save_json("positions.json", [{"ticker": t, **p} for t, p in portfolio.positions.items()])
+
+ snapshot = {
+ "time": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "last_history": portfolio.history[-1] if portfolio.history else None,
+ "positions": [{"ticker": t, **p} for t, p in portfolio.positions.items()],
+ "signals": [asdict(s) for s in sig_list],
+ "capital_start": START_CAPITAL
+ }
+ save_json("snapshot.json", snapshot)
+
+ last_dump = now
+
+ if portfolio.history:
+ print(pd.DataFrame(portfolio.history).tail(1).to_string(index=False))
+ else:
+ print("Brak sygnałów w oknie – czekam...")
+
+ except KeyboardInterrupt:
+ print("\nStopping workers...")
+ stop_evt.set()
+ for p in procs:
+ p.join(timeout=5)
+
+if __name__ == "__main__":
+ main()
diff --git a/portfolio.py b/portfolio.py
new file mode 100644
index 0000000..cee0213
--- /dev/null
+++ b/portfolio.py
@@ -0,0 +1,137 @@
+# portfolio.py
+from __future__ import annotations
+import math, time, json
+from typing import Dict, List, Any
+
+def _to_native(obj: Any):
+ """Bezpieczne rzutowanie do typów akceptowalnych przez JSON."""
+ if isinstance(obj, (float, int, str)) or obj is None:
+ return obj
+ if isinstance(obj, dict):
+ return {k: _to_native(v) for k, v in obj.items()}
+ if isinstance(obj, (list, tuple)):
+ return [_to_native(x) for x in obj]
+ try:
+ return float(obj)
+ except Exception:
+ return str(obj)
+
+def save_json(path: str, data: Any) -> None:
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(_to_native(data), f, ensure_ascii=False, indent=2)
+
+class Portfolio:
+ """
+ Prosta symulacja portfela:
+ - cash
+ - positions: {ticker: {"qty": float, "entry": float, "side": int}}
+ - equity = cash + niezrealizowany PnL
+ Logi transakcji z realized PnL (wartość i %), plus PnL skumulowany.
+ Zapis tylko w JSON.
+ """
+ def __init__(self, capital: float):
+ self.cash = float(capital)
+ self.start_capital = float(capital)
+ self.positions: Dict[str, Dict] = {}
+ self.history: List[Dict] = []
+ self.trade_log: List[Dict] = []
+ self.realized_pnl: float = 0.0 # skumulowany realized PnL
+
+ def mark_to_market(self, prices: Dict[str, float]) -> float:
+ unreal = 0.0
+ for t, p in self.positions.items():
+ price = prices.get(t)
+ if price is not None and not math.isnan(price):
+ unreal += (price - p["entry"]) * p["qty"] * p["side"]
+ return self.cash + unreal
+
+ def _log_trade(self, action: str, ticker: str, price: float, qty: float, side: int,
+ pnl_abs: float = 0.0, pnl_pct: float = 0.0):
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
+ side_str = "LONG" if side == 1 else "SHORT"
+ if action == "BUY":
+ print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={side_str}, cash={self.cash:.2f}")
+ elif action == "SELL":
+ print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, "
+ f"PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), "
+ f"cumPnL={self.realized_pnl:+.2f}, cash={self.cash:.2f}")
+ # zapis do logu w pamięci
+ self.trade_log.append({
+ "time": ts,
+ "action": action,
+ "ticker": ticker,
+ "price": float(price),
+ "qty": float(qty),
+ "side": int(side),
+ "pnl_abs": float(pnl_abs),
+ "pnl_pct": float(pnl_pct),
+ "realized_pnl_cum": float(self.realized_pnl),
+ "cash_after": float(self.cash)
+ })
+
+ def on_signals(self, sigs: List[dict]):
+ """
+ sigs: lista dictów/obiektów z polami: ticker, price, signal, time
+ BUY (1) / SELL (-1) / HOLD (0)
+ """
+ clean = []
+ for s in sigs:
+ if isinstance(s, dict):
+ if s.get("error") is None and s.get("price") is not None and not math.isnan(s.get("price", float("nan"))):
+ clean.append(s)
+ else:
+ if getattr(s, "error", None) is None and not math.isnan(getattr(s, "price", float("nan"))):
+ clean.append({"ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time})
+
+ if not clean:
+ self.history.append({
+ "time": time.strftime("%Y-%m-%d %H:%M:%S"),
+ "equity": self.cash,
+ "cash": self.cash,
+ "open_positions": len(self.positions)
+ })
+ save_json("portfolio_history.json", self.history)
+ return
+
+ prices = {s["ticker"]: s["price"] for s in clean}
+ n = len(clean)
+ per_trade_cash = max(self.cash / (n * 2), 0.0)
+
+ for s in clean:
+ t, sig, price = s["ticker"], int(s["signal"]), float(s["price"])
+
+ # zamknięcie lub odwrócenie
+ if t in self.positions:
+ pos = self.positions[t]
+ if sig == 0 or sig != pos["side"]:
+ qty = pos["qty"]
+ entry = pos["entry"]
+ side = pos["side"]
+ self.cash += qty * price
+ pnl_abs = (price - entry) * qty * side
+ denom = max(qty * entry, 1e-12)
+ pnl_pct = pnl_abs / denom
+ self.realized_pnl += pnl_abs
+ self._log_trade("SELL", t, price, qty, side, pnl_abs, pnl_pct)
+ del self.positions[t]
+
+ # otwarcie
+ if t not in self.positions and sig != 0 and per_trade_cash > 0:
+ qty = per_trade_cash / price
+ self.cash -= qty * price
+ self.positions[t] = {"qty": qty, "entry": price, "side": sig}
+ self._log_trade("BUY", t, price, qty, sig)
+
+ equity = self.mark_to_market(prices)
+ self.history.append({
+ "time": clean[0]["time"],
+ "equity": float(equity),
+ "cash": float(self.cash),
+ "open_positions": int(len(self.positions)),
+ "realized_pnl_cum": float(self.realized_pnl)
+ })
+
+ # zapis plików JSON
+ save_json("trade_log.json", self.trade_log)
+ save_json("portfolio_history.json", self.history)
+ save_json("positions.json", [{"ticker": t, **p} for t, p in self.positions.items()])
diff --git a/pythonProject.zip b/pythonProject.zip
new file mode 100644
index 0000000000000000000000000000000000000000..9bf7ff511691759240e92bedb633719bfb970875
GIT binary patch
literal 61134
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
+ x === null || x === undefined || Number.isNaN(x) ? "–" : Number(x).toFixed(d);
+
+// Potencjalne adresy backendu (API). Pierwszy to aktualny origin UI.
+const apiCandidates = [
+ window.location.origin,
+ "http://127.0.0.1:8000",
+ "http://localhost:8000",
+ "http://172.27.20.120:8000", // z logów serwera
+];
+
+let API_BASE = null;
+let warnedMixed = false;
+
+function withTimeout(ms = 6000) {
+ const ctrl = new AbortController();
+ const id = setTimeout(() => ctrl.abort("timeout"), ms);
+ return { signal: ctrl.signal, done: () => clearTimeout(id) };
+}
+
+function makeUrl(path) {
+ return `${API_BASE}${path}${path.includes("?") ? "&" : "?"}_ts=${Date.now()}`;
+}
+
+function setBadge(id, text, ok = true) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.textContent = text;
+ el.className = ok ? "badge ok" : "badge err";
+}
+
+function setBadgeTitle(id, title) {
+ const el = document.getElementById(id);
+ if (el) el.title = title || "";
+}
+
+function warnMixedContent(base) {
+ if (!warnedMixed && location.protocol === "https:" && base?.startsWith("http://")) {
+ warnedMixed = true;
+ console.warn(
+ "[api] UI działa przez HTTPS, a API przez HTTP — przeglądarka może blokować żądania (mixed content)."
+ );
+ alert(
+ "UI działa przez HTTPS, a API przez HTTP. Uruchom UI przez HTTP albo włącz HTTPS dla API — inaczej przeglądarka zablokuje żądania."
+ );
+ }
+}
+
+// ── autodetekcja backendu ─────────────────────────────────────────────────────
+async function pickBackend() {
+ for (const base of apiCandidates) {
+ try {
+ const t = withTimeout(2500);
+ const r = await fetch(`${base}/api/status?_ts=${Date.now()}`, {
+ cache: "no-store",
+ signal: t.signal,
+ });
+ t.done();
+ if (r.ok) {
+ API_BASE = base;
+ console.debug("[api] using", API_BASE);
+ warnMixedContent(API_BASE);
+ setBadgeTitle("loopState", `API: ${API_BASE}`);
+ return;
+ }
+ console.debug("[api] probe", base, "->", r.status);
+ } catch (e) {
+ // ignorujemy i próbujemy kolejny kandydat
+ console.debug("[api] probe fail", base, e?.message || e);
+ }
+ }
+ throw new Error("Nie znaleziono działającego backendu (API_BASE). Upewnij się, że server.py działa na porcie 8000.");
+}
+
+// ── API helpers ───────────────────────────────────────────────────────────────
+async function apiGet(path) {
+ const url = makeUrl(path);
+ const t0 = performance.now();
+ const t = withTimeout(6000);
+ try {
+ const r = await fetch(url, { cache: "no-store", signal: t.signal });
+ const t1 = performance.now();
+ console.debug("[api] GET", url, r.status, (t1 - t0).toFixed(1) + "ms");
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
+ return r.json();
+ } finally {
+ t.done();
+ }
+}
+
+async function apiPost(path, body) {
+ const url = makeUrl(path);
+ const t0 = performance.now();
+ const t = withTimeout(6000);
+ try {
+ const r = await fetch(url, {
+ method: "POST",
+ headers: body ? { "Content-Type": "application/json" } : undefined,
+ body: body ? JSON.stringify(body) : undefined,
+ signal: t.signal,
+ });
+ const t1 = performance.now();
+ console.debug("[api] POST", url, r.status, (t1 - t0).toFixed(1) + "ms");
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
+ return r.json();
+ } finally {
+ t.done();
+ }
+}
+
+// ── refreshers ────────────────────────────────────────────────────────────────
+async function refreshStatus() {
+ try {
+ const s = await apiGet("/api/status");
+ setBadge("loopState", s.running ? "RUNNING" : "STOPPED", s.running);
+ setBadgeTitle("loopState", `API: ${API_BASE} | last_action=${s.last_action || "–"}`);
+
+ const roundEl = document.getElementById("roundNo");
+ if (roundEl) roundEl.textContent = s.round ?? "–";
+
+ const cashEl = document.getElementById("cash");
+ if (cashEl) cashEl.textContent = fmtNum(s.cash, 2);
+
+ // now-playing
+ const stageEl = document.getElementById("stage");
+ if (stageEl) stageEl.textContent = (s.stage || "idle").toUpperCase();
+
+ const tickerEl = document.getElementById("ticker");
+ if (tickerEl) tickerEl.textContent = s.current_ticker || "–";
+
+ const idx = s.current_index ?? 0;
+ const total = s.tickers_total ?? 0;
+
+ const progressTextEl = document.getElementById("progressText");
+ if (progressTextEl) progressTextEl.textContent = `${Math.min(idx + 1, total)} / ${total}`;
+
+ const prog = document.getElementById("progress");
+ if (prog) {
+ prog.max = total || 1;
+ prog.value = Math.min(idx + 1, total) || 0;
+ }
+
+ const lastActionEl = document.getElementById("lastAction");
+ if (lastActionEl) lastActionEl.textContent = s.last_action || "–";
+ } catch (e) {
+ console.error("status error:", e);
+ setBadge("loopState", "ERR", false);
+ setBadgeTitle("loopState", `API: ${API_BASE || "—"} | ${e?.message || e}`);
+ }
+}
+
+async function refreshPositions() {
+ try {
+ const { positions } = await apiGet("/api/positions");
+ const tbody = document.querySelector("#positions tbody");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ for (const p of positions) {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ | ${p.ticker} |
+ ${p.side} |
+ ${fmtNum(p.size, 0)} |
+ ${fmtNum(p.entry_price)} |
+ ${fmtNum(p.last_price)} |
+ ${fmtNum(p.pnl)} |
+ `;
+ tbody.appendChild(tr);
+ }
+ } catch (e) {
+ console.error("positions error:", e);
+ }
+}
+
+async function refreshTrades() {
+ try {
+ const { trades } = await apiGet("/api/trades");
+ const tbody = document.querySelector("#trades tbody");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ trades.slice(-50).reverse().forEach((t) => {
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+ ${t.time ?? ""} |
+ ${t.ticker ?? ""} |
+ ${t.action ?? ""} |
+ ${fmtNum(t.price)} |
+ ${fmtNum(t.size, 0)} |
+ `;
+ tbody.appendChild(tr);
+ });
+ } catch (e) {
+ console.error("trades error:", e);
+ }
+}
+
+async function refreshAll() {
+ await Promise.all([refreshStatus(), refreshPositions(), refreshTrades()]);
+}
+
+// ── auto refresh ──────────────────────────────────────────────────────────────
+let timer = null;
+let currentInterval = 2000; // domyślnie 2s (zgodne z Twoim selectem)
+
+function startAutoRefresh() {
+ if (timer) return;
+ timer = setInterval(refreshAll, currentInterval);
+ console.debug("[ui] auto refresh started", currentInterval, "ms");
+}
+function stopAutoRefresh() {
+ if (!timer) return;
+ clearInterval(timer);
+ timer = null;
+ console.debug("[ui] auto refresh stopped");
+}
+
+// ── bootstrap ────────────────────────────────────────────────────────────────
+document.addEventListener("DOMContentLoaded", async () => {
+ // Przyciski
+ document.getElementById("btnStart")?.addEventListener("click", async () => {
+ try { await apiPost("/api/start"); await refreshStatus(); } catch (e) { console.error(e); }
+ });
+ document.getElementById("btnStop")?.addEventListener("click", async () => {
+ try { await apiPost("/api/stop"); await refreshStatus(); } catch (e) { console.error(e); }
+ });
+ document.getElementById("btnTick")?.addEventListener("click", async () => {
+ try { await apiPost("/api/run-once"); await refreshAll(); } catch (e) { console.error(e); }
+ });
+
+ const sel = document.getElementById("refreshMs");
+ sel?.addEventListener("change", () => {
+ currentInterval = parseInt(sel.value, 10);
+ if (timer) { stopAutoRefresh(); startAutoRefresh(); }
+ });
+
+ document.getElementById("autoOn")?.addEventListener("click", startAutoRefresh);
+ document.getElementById("autoOff")?.addEventListener("click", stopAutoRefresh);
+
+ // Autodetekcja backendu przed pierwszym odświeżeniem
+ try {
+ await pickBackend();
+ await refreshAll();
+ startAutoRefresh();
+ } catch (e) {
+ console.error(e);
+ setBadge("loopState", "NO API", false);
+ setBadgeTitle("loopState", e?.message || String(e));
+ alert("UI nie może połączyć się z backendem (port 8000). Uruchom server.py lub zaktualizuj API_BASE w app.js.");
+ }
+});
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..17f4d82
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,41 @@
+:root{
+ --border:#e5e7eb;
+ --muted:#64748b;
+}
+*{ box-sizing:border-box; }
+body{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin:0; color:#0f172a; background:#fff; }
+.container{ max-width: 1100px; margin: 0 auto; padding: 16px; }
+header{ display:flex; align-items:center; justify-content:space-between; margin-bottom: 16px; gap: 12px; }
+h1{ margin:0; font-size: 22px; }
+h2{ font-size:18px; margin: 18px 0 10px; }
+h3{ font-size:16px; margin:0; }
+
+#statusBar{ display:flex; align-items:center; gap: 12px; flex-wrap: wrap; }
+#statusBar span{ font-size:14px; color:#0f172a; }
+.btn{
+ appearance:none; border:1px solid var(--border); background:#0f172a; color:#fff;
+ padding:8px 12px; border-radius:10px; cursor:pointer; font-weight:600;
+}
+.btn:hover{ opacity:.9; }
+.btn-secondary{ background:#475569; }
+
+.grid{ display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 24px; }
+.card{ background:#0f172a0d; border:1px solid var(--border); border-radius:12px; padding:12px; }
+.kpi{ font-size: 24px; font-weight: 700; margin-top: 8px; }
+.table-wrap{ overflow:auto; border:1px solid var(--border); border-radius:12px; }
+table{ width:100%; border-collapse: collapse; font-size: 14px; }
+th, td{ padding: 8px 10px; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
+thead th{ position: sticky; top: 0; background: #fff; z-index: 1; }
+tbody tr:hover{ background:#f8fafc; }
+
+.pnl-pos{ color: #166534; font-weight:600; }
+.pnl-neg{ color: #991b1b; font-weight:600; }
+
+.badge{
+ padding:3px 8px; border-radius:999px; font-size:12px; border:1px solid var(--border);
+ display:inline-block;
+}
+.badge.long{ background:#ecfdf5; border-color:#bbf7d0; }
+.badge.short{ background:#fef2f2; border-color:#fecaca; }
+
+.empty{ text-align:center; color: var(--muted); padding: 14px; }
diff --git a/strategy.py b/strategy.py
new file mode 100644
index 0000000..bbb3c2b
--- /dev/null
+++ b/strategy.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+from dataclasses import dataclass
+import math
+import numpy as np
+import pandas as pd
+from config import CFG
+# zakładam, że masz te funkcje gdzieś u siebie:
+# from indicators import ema, rsi, atr, macd, adx_val
+from indicators import ema, rsi, atr, macd, adx_val
+
+Signal = str # "BUY" | "SELL" | "NONE"
+
+@dataclass
+class Decision:
+ signal: Signal
+ sl: float | None
+ tp: float | None
+ rpu: float # risk per unit (odległość od SL)
+
+def evaluate_signal(df: pd.DataFrame) -> Decision:
+ """
+ Warunki (poluzowane / opcjonalne):
+ - (opcjonalnie) kierunek EMA200 (trend filter),
+ - ADX >= CFG.adx_min,
+ - ATR >= CFG.atr_min_frac_price * price,
+ - RSI nieprzeciążone (BUY < rsi_buy_max, SELL > rsi_sell_min),
+ - (opcjonalnie) MACD zgodny z kierunkiem.
+ Wyjście: SL = k_ATR, TP = RR * R (R = dystans do SL).
+ """
+ if df is None or len(df) < CFG.history_min_bars:
+ return Decision("NONE", None, None, 0.0)
+
+ # Bezpieczne pobranie kolumn
+ cols = {c.lower(): c for c in df.columns}
+ C = pd.to_numeric(df[cols.get("close","Close")], errors="coerce").to_numpy(float)
+ H = pd.to_numeric(df[cols.get("high","High")], errors="coerce").to_numpy(float)
+ L = pd.to_numeric(df[cols.get("low","Low")], errors="coerce").to_numpy(float)
+
+ if len(C) == 0 or np.isnan(C[-1]):
+ return Decision("NONE", None, None, 0.0)
+
+ # wskaźniki
+ ema200 = ema(C, 200)
+ rsi14 = rsi(C, CFG.rsi_len)
+ atr14 = atr(H, L, C, 14)
+ macd_line, macd_sig, macd_hist = macd(C, 12, 26, 9)
+ adx14, pdi, mdi = adx_val(H, L, C, 14)
+
+ c = float(C[-1])
+ a = float(atr14[-1]) if math.isfinite(atr14[-1]) else 0.0
+
+ if not (math.isfinite(c) and math.isfinite(a)) or a <= 0:
+ return Decision("NONE", None, None, 0.0)
+
+ # filtry bazowe
+ adx_ok = float(adx14[-1]) >= CFG.adx_min
+ atr_ok = (a / max(1e-9, c)) >= CFG.atr_min_frac_price
+
+ # trend (opcjonalny)
+ trend_ok_buy = True if not CFG.require_trend else (c > float(ema200[-1]))
+ trend_ok_sell = True if not CFG.require_trend else (c < float(ema200[-1]))
+
+ # RSI „nieprzeciążone”
+ rsi_val = float(rsi14[-1])
+ rsi_ok_buy = rsi_val <= CFG.rsi_buy_max
+ rsi_ok_sell = rsi_val >= CFG.rsi_sell_min
+
+ # MACD (opcjonalny)
+ if CFG.use_macd_filter:
+ macd_buy = (macd_line[-1] > macd_sig[-1] and macd_hist[-1] > 0)
+ macd_sell = (macd_line[-1] < macd_sig[-1] and macd_hist[-1] < 0)
+ else:
+ macd_buy = macd_sell = True
+
+ signal: Signal = "NONE"
+ sl = tp = None
+ rpu = 0.0
+
+ if adx_ok and atr_ok:
+ if trend_ok_buy and rsi_ok_buy and macd_buy:
+ signal = "BUY"
+ sl = c - CFG.sl_atr_mult * a
+ risk = c - sl
+ tp = c + CFG.tp_rr * risk
+ rpu = max(1e-9, risk)
+ elif CFG.allow_short and trend_ok_sell and rsi_ok_sell and macd_sell:
+ signal = "SELL"
+ sl = c + CFG.sl_atr_mult * a
+ risk = sl - c
+ tp = c - CFG.tp_rr * risk
+ rpu = max(1e-9, risk)
+
+ return Decision(signal, sl, tp, rpu)
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..c55391a
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+ Trader – Panel
+
+
+
+
+
+ Loop:
+ –
+
+
+
+
+ | Auto-refresh:
+
+
+
+
+ | Runda: –
+ | Gotówka: –
+
+
+
+
Teraz:
+
–
+
–
+
0 / 0
+
+
Ostatnia akcja:
+
–
+
+
+
+
+
Pozycje
+
+
+ | Ticker | Strona | Ilość | Wejście | Ostatnia | PnL |
+
+
+
+
+
+
Transakcje (ostatnie 50)
+
+
+ | Czas | Ticker | Akcja | Cena | Ilość |
+
+
+
+
+
+
+
+
+
diff --git a/trader.py b/trader.py
new file mode 100644
index 0000000..8c31d16
--- /dev/null
+++ b/trader.py
@@ -0,0 +1,264 @@
+# 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