From 7e722ca77f3e85a2822565f630d5cc9c4bb44eea Mon Sep 17 00:00:00 2001 From: Patryk Zamorski Date: Fri, 15 Aug 2025 12:32:27 +0200 Subject: [PATCH] init2 --- README.md | 226 -------- app.py | 138 +++-- config.py | 57 -- data.py | 84 --- indicators.py | 99 +++- install.sh | 14 - io_utils.py | 37 -- metrics.py | 26 - new/app.py | 61 -- new/indicators.py | 125 ---- new/new.zip | Bin 67567 -> 0 bytes new/portfolio.py | 333 ----------- new/portfolio_history.json | 79 --- new/positions.json | 26 - new/static/app.js | 221 ------- new/templates/index.html | 114 ---- new/trade_log.json | 28 - portfolio.py | 338 ++++++++--- portfolio_history.json | 24 + positions.json | 1 + pythonProject.zip | Bin 61134 -> 0 bytes requirements.txt | 4 - server.py | 83 --- new/signals_scan.json => signals_scan.json | 592 +++++++++---------- new/snapshot.json => snapshot.json | 637 ++++++++++----------- static/app.js | 414 ++++++------- static/style.css | 41 -- {new/static => static}/styles.css | 0 new/strategies.py => strategies.py | 0 strategy.py | 93 --- templates/index.html | 155 +++-- trade_log.json | 28 + trader.py | 264 --------- new/trading_bot.py => trading_bot.py | 0 34 files changed, 1347 insertions(+), 2995 deletions(-) delete mode 100644 README.md delete mode 100644 config.py delete mode 100644 data.py delete mode 100644 install.sh delete mode 100644 io_utils.py delete mode 100644 metrics.py delete mode 100644 new/app.py delete mode 100644 new/indicators.py delete mode 100644 new/new.zip delete mode 100644 new/portfolio.py delete mode 100644 new/portfolio_history.json delete mode 100644 new/positions.json delete mode 100644 new/static/app.js delete mode 100644 new/templates/index.html delete mode 100644 new/trade_log.json create mode 100644 portfolio_history.json create mode 100644 positions.json delete mode 100644 pythonProject.zip delete mode 100644 requirements.txt delete mode 100644 server.py rename new/signals_scan.json => signals_scan.json (60%) rename new/snapshot.json => snapshot.json (55%) delete mode 100644 static/style.css rename {new/static => static}/styles.css (100%) rename new/strategies.py => strategies.py (100%) delete mode 100644 strategy.py create mode 100644 trade_log.json delete mode 100644 trader.py rename new/trading_bot.py => trading_bot.py (100%) diff --git a/README.md b/README.md deleted file mode 100644 index d0780db..0000000 --- a/README.md +++ /dev/null @@ -1,226 +0,0 @@ -# Multi-Asset Portfolio Simulator (Yahoo Finance, Python) - -Szybki symulator wielo-aktywny z jedną wspólną kasą. -Działa w pętli co 2 minuty i dla paczki tickerów: - -- pobiera **batch** danych z Yahoo jednym zapytaniem (`yfinance.download`), -- **dopina tylko nowe świece** (cache ostatniego czasu per ticker), -- liczy sygnał na **close** każdej świecy, -- **egzekwuje** zlecenie na **kolejnym open** (bardziej realistycznie), -- prowadzi portfel (wspólne saldo, SL/TP intrabar, prowizja, poślizg), -- zapisuje wyniki do `dane/` oraz liczy metryki **MaxDD, Sharpe, CAGR**. - -> Uwaga: to **symulacja** oparta o dane z Yahoo. Nie składa prawdziwych zleceń. - ---- - -## Funkcje (skrót) - -- Multi-asset (~20 domyślnych: krypto + forex + złoto `GC=F`) -- Batch download (znacznie szybciej niż pojedynczo) -- Tylko nowe świece (bez pobierania pełnej historii co rundę) -- Sygnał: EMA200 + SMA(20/50) + RSI(14) + ATR-SL/TP + filtry: - **MACD, Stochastic, Bollinger, ADX, Supertrend** -- Egzekucja na **kolejnym OPEN** -- Portfel: **jedna** kasa dla wszystkich instrumentów, risk per trade (% equity) -- Zapisy CSV + **portfolio_summary** z MaxDD / Sharpe (annual) / CAGR - ---- - -## Wymagania - -- Python 3.9+ -- `pip install yfinance pandas numpy` - ---- - -## Szybki start - -```bash -# 1) instalacja -chmod +x install.sh -./install.sh - -# 2) uruchomienie (wirtualne środowisko) -source venv/bin/activate -python app.py - -# (opcjonalnie w tle) -nohup python app.py > output.log 2>&1 & -``` - -Logi lecą do `stdout` (lub do `output.log` przy `nohup`). - ---- - -## Struktura plików - -``` -project/ -├─ app.py # główna pętla: batch → nowe świece → decyzje → egzekucja → zapis -├─ config.py # konfiguracja (tickery, interwał, risk, katalog 'dane', itp.) -├─ data.py # fetch_batch(): pobieranie paczki tickerów z yfinance -├─ indicators.py # implementacje wskaźników (SMA/EMA/RSI/ATR/MACD/…) -├─ strategy.py # evaluate_signal(): logika generowania sygnałów + SL/TP + rpu -├─ portfolio.py # model portfela: pozycje, pending orders, SL/TP, PnL, equity -├─ metrics.py # metryki portfelowe: MaxDD, Sharpe (annualized), CAGR -├─ io_utils.py # zapisy do 'dane/': trades, equity, summary; tworzenie katalogów -├─ requirements.txt # zależności (yfinance, pandas, numpy) -└─ install.sh # instalator: venv + pip install -r requirements.txt -``` - -### Co jest w każdym pliku? - -- **`config.py`** - Definiuje `CFG` (tickers, interwał, okres pobierania `yf_period`, minimalna historia, risk, kasa startowa, SL/TP, katalog wyjściowy). - Zmienisz tu listę instrumentów lub parametry ryzyka. - -- **`data.py`** - `fetch_batch(tickers, period, interval)` — jedno zapytanie do Yahoo dla wielu tickerów. Zwraca słownik `{ticker: DataFrame}` z kolumnami `open, high, low, close, volume`. - -- **`indicators.py`** - Wskaźniki: `sma, ema, rsi, atr, macd, stoch_kd, bollinger, adx_val, supertrend`. - -- **`strategy.py`** - `evaluate_signal(df)` → `Decision(signal, sl, tp, rpu)` - - **signal**: `BUY` / `SELL` / `NONE` - - **sl/tp**: poziomy na bazie ATR i RR - - **rpu** (*risk per unit*): ile ryzyka na jednostkę (do wyliczenia wielkości pozycji) - -- **`portfolio.py`** - Model portfela z jedną kasą: - - egzekucja **na kolejnym OPEN** (pending orders), - - SL/TP intrabar, - - prowizja i poślizg, - - `portfolio_equity` (czas, equity) i lista `trades`. - -- **`metrics.py`** - Metryki portfela na bazie krzywej equity: - - **Max Drawdown** – minimalna wartość `equity/rolling_max - 1`, - - **Sharpe annualized** – z 1-min zwrotów (525 600 okresów/rok), - - **CAGR** – roczna złożona stopa wzrostu. - -- **`io_utils.py`** - Tworzy strukturę `dane/`, zapisuje: - - `dane/portfolio_equity.csv` – equity w czasie, - - `dane/portfolio_summary.txt` – JSON z metrykami i statystyką, - - `dane//_trades.csv` – dziennik zagrań per instrument. - -- **`app.py`** - Główna pętla: - 1) `fetch_batch` → nowe świeczki, - 2) dla każdego **nowego** bara: najpierw egzekucja pending na **open**, potem sygnał na **close** i zaplanowanie zlecenia na **kolejny open**, - 3) zapis CSV + metryki, - 4) pauza do pełnych 2 minut na rundę. - ---- - -## Struktura wyjściowa (w `dane/`) - -``` -dane/ -├─ portfolio_equity.csv # [time(ms), equity, datetime] -├─ portfolio_summary.txt # JSON z metrykami (MaxDD, Sharpe, CAGR, itp.) -├─ BTC-USD/ -│ └─ BTC-USD_trades.csv # dziennik transakcji dla BTC -├─ ETH-USD/ -│ └─ ETH-USD_trades.csv -└─ ... -``` - -**`*_trades.csv`** (kolumny): -`time, datetime, ticker, action(OPEN/CLOSE), side(long/short), price, size, pnl, reason, equity_after` - ---- - -## Konfiguracja (najczęściej zmieniane) - -Otwórz `config.py`: - -```python -CFG.tickers = ["BTC-USD","ETH-USD","EURUSD=X", ...] # Twoja lista -CFG.interval = "1m" # 1m / 2m / 5m / 15m / 30m / 60m / 1h -CFG.yf_period = "2d" # krótszy = szybciej; dopasuj do interwału -CFG.risk_per_trade = 0.005 # 0.5% equity na trade -CFG.starting_cash = 10000.0 # kasa startowa (w jednostce bazowej) -CFG.sl_atr_mult = 2.0 # ile ATR do SL -CFG.tp_rr = 1.5 # stosunek TP do ryzyka -CFG.root_dir = "dane" # katalog wyjściowy -``` - -> **Tip**: przy zmianie interwału ustaw odpowiednie `yf_period` (np. 1m→2d, 5m→10d, 1h→60d), żeby mieć min. ~250 barów do wskaźników. - ---- - -## Jak to działa (timeline 1 świecy) - -1. Nowy bar `t` przychodzi z Yahoo. -2. Jeśli był **pending order** zaplanowany na **bar `t`**, **otwiera się** na jego **OPEN**. -3. Jeśli jest otwarta pozycja, sprawdzamy **SL/TP intrabar** (`low/high`). -4. Na **CLOSE** bara `t` liczymy sygnał i **plan** (pending) na **OPEN** bara `t+1`. -5. Snapshot portfelowego equity po close. - ---- - -## Metryki - -- **MaxDD**: min wartość z `(equity/rolling_max - 1)`. -- **Sharpe (annualized)**: liczone z 1-min zwrotów `pct_change`, skalowane przez √(525 600). - (Brak stopy wolnej od ryzyka/rf ~ 0 w tej wersji.) -- **CAGR**: `((equity_end/equity_start)^(1/lata)) - 1`. - -> Uwaga: przy bardzo krótkich danych te metryki mogą być niestabilne. - ---- - -## FAQ - -**Q: Chcę inne interwały (np. 5m).** -A: Zmień `CFG.interval = "5m"` oraz `CFG.yf_period = "10d"` (żeby mieć ≥250 barów). - -**Q: Jak dodać/zmienić tickery?** -A: Zedytuj listę w `config.py`. Dla złota używamy `GC=F` (futures). - -**Q: Foldery per ticker nie powstają od razu.** -A: Tworzą się na starcie (`ensure_dirs`). Jeśli jakiś ticker nie ma danych z Yahoo, plik z transakcjami może nie powstać — ale katalog jest. - -**Q: Wolno działa?** -A: Ta wersja używa **jednego** zapytania na rundę + tylko **nowe** świece. Dalsze przyspieszenie: rozbij tickery na dwie paczki, jeśli Yahoo dławi się liczbą symboli. - -**Q: Jak uruchomić jako usługę systemd?** -A: -```ini -# /etc/systemd/system/portfolio.service -[Unit] -Description=Multi-Asset Portfolio Simulator -After=network.target - -[Service] -User=USER -WorkingDirectory=/ścieżka/do/project -ExecStart=/ścieżka/do/project/venv/bin/python /ścieżka/do/project/app.py -Restart=always - -[Install] -WantedBy=multi-user.target -``` -Potem: -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now portfolio.service -journalctl -u portfolio.service -f -``` - ---- - -## Troubleshooting - -- **Yahoo 404 / brak danych dla symbolu** → Yahoo czasem zwraca puste dane. Po prostu pomija dany ticker w tej rundzie. -- **Za mało świec (history_min_bars)** → zwiększ `yf_period`. -- **Błędy czasu** → wymuszamy UTC; jeśli masz własne źródło, ujednolicaj strefę. -- **Sharpe = 0** → zbyt krótki okres lub zerowa zmienność w danych (rzadkie). - ---- - -## Licencja -Ten kod jest przykładowy/edukacyjny; używaj na własną odpowiedzialność. Nie stanowi porady inwestycyjnej. diff --git a/app.py b/app.py index 89c8574..1519601 100644 --- a/app.py +++ b/app.py @@ -1,76 +1,70 @@ from __future__ import annotations -import time -from typing import Dict -import pandas as pd -from config import CFG -from data import fetch_batch -from strategy import evaluate_signal -from portfolio import Portfolio -from io_utils import ensure_dirs, save_outputs +import os, json, threading +from flask import Flask, jsonify, render_template, abort +from trading_bot import main as trading_bot_main # dostosuj jeśli moduł nazywa się inaczej -def interval_to_timedelta(interval: str) -> pd.Timedelta: - # prosta mapka dla 1m/2m/5m/15m/30m/60m/1h - mapping = { - "1m":"1min","2m":"2min","5m":"5min","15m":"15min","30m":"30min", - "60m":"60min","90m":"90min","1h":"60min" - } - key = mapping.get(interval, "1min") - return pd.to_timedelta(key) +# Katalog projektu (tam gdzie leży ten plik) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Domyślnie dane są w głównym katalogu projektu; można nadpisać przez env DATA_DIR +DATA_DIR = os.environ.get("DATA_DIR", BASE_DIR) + +def _load_json(name: str): + path = os.path.join(DATA_DIR, name) + if not os.path.isfile(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return None + +app = Flask( + __name__, + static_folder="static", + static_url_path="/static", # <-- ważne: z wiodącym slashem + template_folder="templates", +) + +@app.route("/") +def index(): + return render_template("index.html") + +@app.route("/api/snapshot") +def api_snapshot(): + data = _load_json("snapshot.json") + if data is None: + abort(404, description="snapshot.json not found") + return jsonify(data) + +@app.route("/api/history") +def api_history(): + data = _load_json("portfolio_history.json") + if data is None: + abort(404, description="portfolio_history.json not found") + return jsonify(data) + +@app.route("/api/positions") +def api_positions(): + data = _load_json("positions.json") + if data is None: + data = [] + return jsonify(data) + +@app.route("/api/trades") +def api_trades(): + data = _load_json("trade_log.json") + if data is None: + data = [] + return jsonify(data) + +def start_trading_bot(): + """Uruchamia bota w osobnym wątku.""" + trading_thread = threading.Thread(target=trading_bot_main, daemon=True) + trading_thread.start() + print("[Flask] Trading bot uruchomiony w wątku.") if __name__ == "__main__": - ensure_dirs(CFG.root_dir, CFG.tickers) - portfolio = Portfolio(starting_cash=CFG.starting_cash, commission_per_trade=1.0, slippage_bp=1.0) - - last_ts: Dict[str, pd.Timestamp] = {} - hist: Dict[str, pd.DataFrame] = {} - bar_delta = interval_to_timedelta(CFG.interval) - - while True: - round_t0 = time.time() - batch = fetch_batch(CFG.tickers, CFG.yf_period, CFG.interval) - - for tk, df in batch.items(): - if df.empty: - continue - df = df.copy() - df.index = pd.to_datetime(df.index, utc=True) - - prev = hist.get(tk) - if prev is not None and not prev.empty: - df_all = pd.concat([prev, df[~df.index.isin(prev.index)]], axis=0).sort_index() - else: - df_all = df.sort_index() - hist[tk] = df_all.tail(2000) - - last = last_ts.get(tk) - new_part = df_all[df_all.index > last] if last is not None else df_all - - if not new_part.empty: - for ts, row in new_part.iterrows(): - o,h,l,c = float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"]) - - # 1) egzekucja oczekujących na OPEN tego baraz - portfolio.on_new_bar(tk, ts, o,h,l,c) - - # 2) sygnał na CLOSE → plan na KOLEJNY OPEN - df_upto = hist[tk].loc[:ts] - dec = evaluate_signal(df_upto) - portfolio.schedule_order( - tk, dec.signal, dec.rpu, dec.sl, dec.tp, - next_bar_ts=ts + pd.to_timedelta(1, unit="min"), - ref_price=float(df_upto["close"].iloc[-1]) - ) - - last_ts[tk] = new_part.index[-1] - - # zapis - import pandas as pd - trades_df = pd.DataFrame(portfolio.trades) - eq_df = pd.DataFrame(portfolio.portfolio_equity, columns=["time","equity"]) - save_outputs(CFG.root_dir, CFG.tickers, trades_df, eq_df, portfolio.cash) - - elapsed = time.time() - round_t0 - sleep_s = max(0, 120 - elapsed) - print(f"Runda OK ({elapsed:.1f}s). Pauza {sleep_s:.1f}s.") - print(sleep_s) - time.sleep(sleep_s) + print(f"[Flask] DATA_DIR: {DATA_DIR}") + start_trading_bot() + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True) diff --git a/config.py b/config.py deleted file mode 100644 index c13491f..0000000 --- a/config.py +++ /dev/null @@ -1,57 +0,0 @@ -from dataclasses import dataclass, field -from typing import List - -@dataclass -class Settings: - # 20 FX - fx: List[str] = field(default_factory=lambda: [ - "EURUSD=X","GBPUSD=X","USDJPY=X","USDCHF=X","USDCAD=X", - "AUDUSD=X","NZDUSD=X","EURGBP=X","EURJPY=X","EURCHF=X", - "GBPJPY=X","AUDJPY=X","CHFJPY=X","CADJPY=X","EURAUD=X", - "EURNZD=X","GBPAUD=X","GBPCAD=X","AUDCAD=X","NZDJPY=X", - ]) - # 20 CRYPTO - crypto: List[str] = field(default_factory=lambda: [ - "BTC-USD","ETH-USD","BNB-USD","SOL-USD","XRP-USD", - "ADA-USD","DOGE-USD","TRX-USD","DOT-USD","AVAX-USD", - "MATIC-USD","LTC-USD","BCH-USD","LINK-USD","ATOM-USD", - "XMR-USD","XLM-USD","ETC-USD","FIL-USD","NEAR-USD", - ]) - - yf_period: str = "5d" - interval: str = "1m" - starting_cash: float = 10000.0 - loop_sleep_s: int = 2 - root_dir: str = "out" - commission_per_trade: float = 1.0 - slippage_bp: float = 1.0 - - # — parametry strategii — - history_min_bars: int = 220 - rsi_len: int = 14 - - # filtry – poluzowane - require_trend: bool = False # ignoruj EMA200 gdy False - use_macd_filter: bool = False # ignoruj MACD gdy False - adx_min: float = 8.0 - atr_min_frac_price: float = 1e-4 - rsi_buy_max: float = 75.0 - rsi_sell_min: float = 25.0 - - # SL/TP - sl_atr_mult: float = 1.3 - tp_rr: float = 1.5 - - # sizing na ryzyko - risk_per_trade_frac: float = 0.02 # 2% equity na 1R - min_size: float = 1.0 - max_size: float = 100000.0 - - # shorty - allow_short: bool = True - - @property - def tickers(self) -> List[str]: - return self.fx + self.crypto - -CFG = Settings() diff --git a/data.py b/data.py deleted file mode 100644 index 4ba3ff1..0000000 --- a/data.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations -from typing import Dict, List -import time, logging -import pandas as pd -import yfinance as yf - -log = logging.getLogger("data") - -def _normalize_ohlc(df: pd.DataFrame) -> pd.DataFrame: - """Ujednolica kolumny do: Open, High, Low, Close, Volume. Obsługa lowercase, MultiIndex, Adj Close.""" - if df is None or len(df) == 0: - return pd.DataFrame(columns=["Open","High","Low","Close","Volume"]) - - df = df.copy() - - # Flatten MultiIndex -> zwykłe nazwy - if isinstance(df.columns, pd.MultiIndex): - df.columns = [str(tuple(filter(None, map(str, c)))).strip("()").replace("'", "").replace(" ", "") if isinstance(c, tuple) else str(c) for c in df.columns] - # po flatten często nazwy są np. 'Close,EURUSD=X' – weź pierwszy człon przed przecinkiem - df.columns = [c.split(",")[0] for c in df.columns] - - # Zrzuć TZ - if isinstance(df.index, pd.DatetimeIndex) and df.index.tz is not None: - df.index = df.index.tz_localize(None) - - # Ujednolicenie do TitleCase - norm = {c: str(c).strip() for c in df.columns} - # mapuj najczęstsze warianty - mapping = {} - for c in norm.values(): - lc = c.lower() - if lc in ("open", "op", "o"): mapping[c] = "Open" - elif lc in ("high", "hi", "h"): mapping[c] = "High" - elif lc in ("low", "lo", "l"): mapping[c] = "Low" - elif lc in ("close", "cl", "c"): mapping[c] = "Close" - elif lc in ("adj close","adjclose","adjustedclose"): mapping[c] = "Adj Close" - elif lc in ("volume","vol","v"): mapping[c] = "Volume" - else: - # zostaw jak jest (np. 'Dividends', 'Stock Splits') - mapping[c] = c - - df.rename(columns=mapping, inplace=True) - - # Jeśli brak Close, ale jest Adj Close -> użyj go - if "Close" not in df.columns and "Adj Close" in df.columns: - df["Close"] = df["Adj Close"] - - # Upewnij się, że są wszystkie podstawowe kolumny (dodaj puste jeśli brak) - for need in ["Open","High","Low","Close","Volume"]: - if need not in df.columns: - df[need] = pd.NA - - # Pozostaw tylko rdzeń (kolejność stała) - df = df[["Open","High","Low","Close","Volume"]] - return df - -def _fetch_single(ticker: str, period: str, interval: str, tries: int = 3, sleep_s: float = 0.7) -> pd.DataFrame: - """Pobierz OHLC dla 1 tickera (z retry) i znormalizuj kolumny.""" - for i in range(1, tries + 1): - try: - log.info("Yahoo: get %s (try %d/%d) period=%s interval=%s", ticker, i, tries, period, interval) - df = yf.Ticker(ticker).history(period=period, interval=interval, auto_adjust=False, prepost=False) - df = _normalize_ohlc(df) - if len(df) == 0 or "Close" not in df.columns: - log.warning("Yahoo: %s -> EMPTY or no Close (cols=%s)", ticker, list(df.columns)) - return df - except Exception as e: - log.warning("Yahoo: error %s (try %d/%d): %s", ticker, i, tries, e) - time.sleep(sleep_s) - # po niepowodzeniu zwróć pusty rdzeń - return pd.DataFrame(columns=["Open","High","Low","Close","Volume"]) - -def fetch_batch(tickers: List[str], period: str, interval: str) -> Dict[str, pd.DataFrame]: - """Pobierz paczkę danych dla listy tickerów.""" - out: Dict[str, pd.DataFrame] = {} - for tk in tickers: - df = _fetch_single(tk, period, interval) - out[tk] = df - if len(df): - first = str(df.index[0]); last = str(df.index[-1]) - else: - first = last = "-" - log.info("Yahoo: %s -> bars=%d %s..%s cols=%s", tk, len(df), first, last, list(df.columns)) - return out diff --git a/indicators.py b/indicators.py index 0cc2e1c..6ebd7e4 100644 --- a/indicators.py +++ b/indicators.py @@ -1,33 +1,50 @@ +# indicators.py from __future__ import annotations import math -from typing import Tuple import numpy as np import pandas as pd +# ===== Helper ===== +def _as_1d(a) -> np.ndarray: + return np.asarray(a, dtype=float).ravel() + +# ===== Indicators ===== def sma(arr: np.ndarray, period: int) -> np.ndarray: - if len(arr) < period: return np.full(len(arr), np.nan) + arr = _as_1d(arr); n = len(arr) + if n == 0: return np.array([], float) + if n < period: return np.full(n, np.nan) w = np.ones(period) / period out = np.convolve(arr, w, mode="valid") return np.concatenate([np.full(period-1, np.nan), out]) def ema(arr: np.ndarray, period: int) -> np.ndarray: - out = np.full(len(arr), np.nan, dtype=float); k = 2/(period+1); e = np.nan - for i,x in enumerate(arr): - e = x if math.isnan(e) else x*k + e*(1-k) + arr = _as_1d(arr); n = len(arr) + out = np.full(n, np.nan, float) + if n == 0: return out + k = 2/(period+1); e = np.nan + for i, x in enumerate(arr): + e = x if (isinstance(e, float) and math.isnan(e)) else x*k + e*(1-k) out[i] = e return out -def rsi(arr: np.ndarray, period: int=14) -> np.ndarray: - if len(arr) < period+1: return np.full(len(arr), np.nan) - d = np.diff(arr, prepend=arr[0]); g = np.where(d>0,d,0.0); L = np.where(d<0,-d,0.0) - ag, al = ema(g,period), ema(L,period); rs = ag/(al+1e-9) +def rsi(arr: np.ndarray, period: int = 14) -> np.ndarray: + arr = _as_1d(arr); n = len(arr) + if n == 0: return np.array([], float) + if n < period+1: return np.full(n, np.nan) + d = np.diff(arr, prepend=arr[0]) + g = np.where(d > 0, d, 0.0) + L = np.where(d < 0, -d, 0.0) + ag, al = ema(g, period), ema(L, period) + rs = ag / (al + 1e-9) return 100 - (100 / (1 + rs)) -def atr(H: np.ndarray, L: np.ndarray, C: np.ndarray, period: int=14) -> np.ndarray: - if len(C) < period+1: return np.full(len(C), np.nan) - pc = np.roll(C,1) - tr = np.maximum.reduce([H-L, np.abs(H-pc), np.abs(L-pc)]) - tr[0] = H[0]-L[0] +def atr(H: np.ndarray, L: np.ndarray, C: np.ndarray, period: int = 14) -> np.ndarray: + H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) + if n == 0: return np.array([], float) + if n < period+1: return np.full(n, np.nan) + pc = np.roll(C, 1) + tr = np.maximum.reduce([H - L, np.abs(H - pc), np.abs(L - pc)]) + tr[0] = H[0] - L[0] return ema(tr, period) def macd(arr: np.ndarray, fast=12, slow=26, signal=9): @@ -38,7 +55,11 @@ def macd(arr: np.ndarray, fast=12, slow=26, signal=9): return line, sig, hist def stoch_kd(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14, smooth=3): - if len(C) < period: return np.full(len(C), np.nan), np.full(len(C), np.nan) + H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) + if n == 0: + z = np.array([], float); return z, z + if n < period: + return np.full(n, np.nan), np.full(n, np.nan) lowest = pd.Series(L).rolling(period, min_periods=1).min().to_numpy() highest = pd.Series(H).rolling(period, min_periods=1).max().to_numpy() k = 100 * (C - lowest) / (highest - lowest + 1e-9) @@ -46,31 +67,59 @@ def stoch_kd(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14, smooth=3): return k, d def bollinger(arr: np.ndarray, period=20, dev=2.0): + arr = _as_1d(arr); n = len(arr) + if n == 0: + z = np.array([], float); return z, z, z s = pd.Series(arr) - ma = s.rollizng(period, min_periods=1).mean().to_numpy() + ma = s.rolling(period, min_periods=1).mean().to_numpy() sd = s.rolling(period, min_periods=1).std(ddof=0).to_numpy() return ma, ma + dev*sd, ma - dev*sd def adx_val(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14): - up_move = H - np.roll(H,1); down_move = np.roll(L,1) - L + H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) + if n == 0: + z = np.array([], float); return z, z, z + up_move = H - np.roll(H, 1); down_move = np.roll(L, 1) - L up_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0) down_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0) - tr1 = H - L; tr2 = np.abs(H - np.roll(C,1)); tr3 = np.abs(L - np.roll(C,1)) + tr1 = H - L; tr2 = np.abs(H - np.roll(C, 1)); tr3 = np.abs(L - np.roll(C, 1)) tr = np.maximum.reduce([tr1, tr2, tr3]); tr[0] = tr1[0] atr14 = ema(tr, period) - pdi = 100 * ema(up_dm, period)/(atr14+1e-9) - mdi = 100 * ema(down_dm, period)/(atr14+1e-9) - dx = 100 * np.abs(pdi - mdi)/(pdi + mdi + 1e-9) + pdi = 100 * ema(up_dm, period) / (atr14 + 1e-9) + mdi = 100 * ema(down_dm, period) / (atr14 + 1e-9) + dx = 100 * np.abs(pdi - mdi) / (pdi + mdi + 1e-9) adx = ema(dx, period) return adx, pdi, mdi def supertrend(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=10, mult=3.0): + H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) + if n == 0: return np.array([], float) a = atr(H, L, C, period).copy() - hl2 = (H + L)/2.0 - ub = hl2 + mult*a - lb = hl2 - mult*a - n = len(C); st = np.full(n, np.nan) + hl2 = (H + L) / 2.0 + ub = hl2 + mult * a + lb = hl2 - mult * a + st = np.full(n, np.nan) for i in range(1, n): prev = st[i-1] if not np.isnan(st[i-1]) else hl2[i-1] st[i] = ub[i] if C[i-1] <= prev else lb[i] return st + +# ===== Pipeline: add_indicators ===== +def add_indicators(df: pd.DataFrame, min_bars: int = 200) -> pd.DataFrame: + if len(df) < min_bars: + raise ValueError(f"Too few bars: {len(df)} < {min_bars}") + C, H, L = df["close"].to_numpy(), df["high"].to_numpy(), df["low"].to_numpy() + df["ema20"] = ema(C, 20) + df["ema50"] = ema(C, 50) + df["rsi14"] = rsi(C, 14) + df["atr14"] = atr(H, L, C, 14) + m_line, m_sig, _ = macd(C) + df["macd"], df["macd_sig"] = m_line, m_sig + k, d = stoch_kd(H, L, C, 14, 3) + df["stoch_k"], df["stoch_d"] = k, d + mid, up, dn = bollinger(C, 20, 2.0) + df["bb_mid"], df["bb_up"], df["bb_dn"] = mid, up, dn + adx_, pdi, mdi = adx_val(H, L, C, 14) + df["adx"], df["pdi"], df["mdi"] = adx_, pdi, mdi + df["supertrend"] = supertrend(H, L, C, 10, 3.0) + return df diff --git a/install.sh b/install.sh deleted file mode 100644 index b9b85b4..0000000 --- a/install.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -e -python3 -m venv venv -source venv/bin/activate -pip install --upgrade pip -pip install -r requirements.txt -echo "OK. Start:" -echo "source venv/bin/activate && python app.py" - - -#chmod +x install.sh -#./install.sh -#source venv/bin/activate -#nohup python app.py > output.log 2>&1 & \ No newline at end of file diff --git a/io_utils.py b/io_utils.py deleted file mode 100644 index 3f7a457..0000000 --- a/io_utils.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations -import os, json -import pandas as pd -from typing import List -from metrics import portfolio_metrics - -def ensure_dirs(root_dir: str, tickers: List[str]): - os.makedirs(root_dir, exist_ok=True) - for tk in tickers: - os.makedirs(os.path.join(root_dir, tk), exist_ok=True) - -def save_outputs(root_dir: str, tickers: List[str], trades: pd.DataFrame, portfolio_eq: pd.DataFrame, cash: float): - ensure_dirs(root_dir, tickers) - - if not trades.empty: - for tk, tdf in trades.groupby("ticker"): - tdf2 = tdf.copy() - tdf2["datetime"] = pd.to_datetime(tdf2["time"], unit="ms", utc=True) - tdf2.to_csv(os.path.join(root_dir, tk, f"{tk}_trades.csv"), index=False) - - if not portfolio_eq.empty: - portfolio_eq["datetime"] = pd.to_datetime(portfolio_eq["time"], unit="ms", utc=True) - portfolio_eq.to_csv(os.path.join(root_dir, "portfolio_equity.csv"), index=False) - - eq_series = portfolio_eq.set_index("time")["equity"] if not portfolio_eq.empty else pd.Series(dtype=float) - metrics = portfolio_metrics(eq_series) if not eq_series.empty else {"max_dd":0.0,"sharpe":0.0,"cagr":0.0} - - summary = { - "ending_cash": round(cash, 2), - "n_open_positions": int(trades[trades["action"]=="OPEN"]["ticker"].nunique()) if not trades.empty else 0, - "n_trades_closed": int((trades["action"]=="CLOSE").sum()) if not trades.empty else 0, - "max_drawdown": metrics["max_dd"], - "sharpe_annualized": metrics["sharpe"], - "cagr": metrics["cagr"], - } - with open(os.path.join(root_dir, "portfolio_summary.txt"), "w", encoding="utf-8") as f: - json.dump(summary, f, indent=2, ensure_ascii=False) diff --git a/metrics.py b/metrics.py deleted file mode 100644 index 7b55542..0000000 --- a/metrics.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations -import math -import pandas as pd -from typing import Dict - -def portfolio_metrics(equity: pd.Series) -> Dict[str, float]: - if equity.empty or equity.iloc[0] <= 0: - return {"max_dd": 0.0, "sharpe": 0.0, "cagr": 0.0} - - roll_max = equity.cummax() - dd = (equity/roll_max) - 1.0 - max_dd = float(dd.min()) - - rets = equity.pct_change().dropna() - if rets.empty or rets.std(ddof=0) == 0: - sharpe = 0.0 - else: - periods_per_year = 365*24*60 # dla 1m - sharpe = float((rets.mean()/rets.std(ddof=0)) * math.sqrt(periods_per_year)) - - t0 = pd.to_datetime(equity.index[0], unit="ms", utc=True) - t1 = pd.to_datetime(equity.index[-1], unit="ms", utc=True) - years = max((t1 - t0).total_seconds() / (365.25*24*3600), 1e-9) - cagr = float((equity.iloc[-1]/equity.iloc[0])**(1/years) - 1.0) - - return {"max_dd": max_dd, "sharpe": sharpe, "cagr": cagr} diff --git a/new/app.py b/new/app.py deleted file mode 100644 index b855b43..0000000 --- a/new/app.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations -import os, json, threading -from flask import Flask, jsonify, render_template, abort -from trading_bot import main as trading_bot_main # zakładam, że tak nazywasz plik - -DATA_DIR = os.environ.get("DATA_DIR", ".") - -def _load_json(name: str): - path = os.path.join(DATA_DIR, name) - if not os.path.isfile(path): - return None - with open(path, "r", encoding="utf-8") as f: - try: - return json.load(f) - except Exception: - return None - -app = Flask( - __name__, - static_folder="static", - static_url_path="/static", - template_folder="templates" -) - -@app.route("/") -def index(): - return render_template("index.html") - -@app.route("/api/snapshot") -def api_snapshot(): - data = _load_json("snapshot.json") - if data is None: abort(404, "snapshot.json not found") - return jsonify(data) - -@app.route("/api/history") -def api_history(): - data = _load_json("portfolio_history.json") - if data is None: abort(404, "portfolio_history.json not found") - return jsonify(data) - -@app.route("/api/positions") -def api_positions(): - data = _load_json("positions.json") - if data is None: data = [] - return jsonify(data) - -@app.route("/api/trades") -def api_trades(): - data = _load_json("trade_log.json") - if data is None: data = [] - return jsonify(data) - -def start_trading_bot(): - """Uruchamia bota w osobnym wątku.""" - trading_thread = threading.Thread(target=trading_bot_main, daemon=True) - trading_thread.start() - print("[Flask] Trading bot uruchomiony w wątku.") - -if __name__ == "__main__": - start_trading_bot() - app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True) diff --git a/new/indicators.py b/new/indicators.py deleted file mode 100644 index 6ebd7e4..0000000 --- a/new/indicators.py +++ /dev/null @@ -1,125 +0,0 @@ -# indicators.py -from __future__ import annotations -import math -import numpy as np -import pandas as pd - -# ===== Helper ===== -def _as_1d(a) -> np.ndarray: - return np.asarray(a, dtype=float).ravel() - -# ===== Indicators ===== -def sma(arr: np.ndarray, period: int) -> np.ndarray: - arr = _as_1d(arr); n = len(arr) - if n == 0: return np.array([], float) - if n < period: return np.full(n, np.nan) - w = np.ones(period) / period - out = np.convolve(arr, w, mode="valid") - return np.concatenate([np.full(period-1, np.nan), out]) - -def ema(arr: np.ndarray, period: int) -> np.ndarray: - arr = _as_1d(arr); n = len(arr) - out = np.full(n, np.nan, float) - if n == 0: return out - k = 2/(period+1); e = np.nan - for i, x in enumerate(arr): - e = x if (isinstance(e, float) and math.isnan(e)) else x*k + e*(1-k) - out[i] = e - return out - -def rsi(arr: np.ndarray, period: int = 14) -> np.ndarray: - arr = _as_1d(arr); n = len(arr) - if n == 0: return np.array([], float) - if n < period+1: return np.full(n, np.nan) - d = np.diff(arr, prepend=arr[0]) - g = np.where(d > 0, d, 0.0) - L = np.where(d < 0, -d, 0.0) - ag, al = ema(g, period), ema(L, period) - rs = ag / (al + 1e-9) - return 100 - (100 / (1 + rs)) - -def atr(H: np.ndarray, L: np.ndarray, C: np.ndarray, period: int = 14) -> np.ndarray: - H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) - if n == 0: return np.array([], float) - if n < period+1: return np.full(n, np.nan) - pc = np.roll(C, 1) - tr = np.maximum.reduce([H - L, np.abs(H - pc), np.abs(L - pc)]) - tr[0] = H[0] - L[0] - return ema(tr, period) - -def macd(arr: np.ndarray, fast=12, slow=26, signal=9): - ef, es = ema(arr, fast), ema(arr, slow) - line = ef - es - sig = ema(line, signal) - hist = line - sig - return line, sig, hist - -def stoch_kd(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14, smooth=3): - H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) - if n == 0: - z = np.array([], float); return z, z - if n < period: - return np.full(n, np.nan), np.full(n, np.nan) - lowest = pd.Series(L).rolling(period, min_periods=1).min().to_numpy() - highest = pd.Series(H).rolling(period, min_periods=1).max().to_numpy() - k = 100 * (C - lowest) / (highest - lowest + 1e-9) - d = pd.Series(k).rolling(smooth, min_periods=1).mean().to_numpy() - return k, d - -def bollinger(arr: np.ndarray, period=20, dev=2.0): - arr = _as_1d(arr); n = len(arr) - if n == 0: - z = np.array([], float); return z, z, z - s = pd.Series(arr) - ma = s.rolling(period, min_periods=1).mean().to_numpy() - sd = s.rolling(period, min_periods=1).std(ddof=0).to_numpy() - return ma, ma + dev*sd, ma - dev*sd - -def adx_val(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14): - H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) - if n == 0: - z = np.array([], float); return z, z, z - up_move = H - np.roll(H, 1); down_move = np.roll(L, 1) - L - up_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0) - down_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0) - tr1 = H - L; tr2 = np.abs(H - np.roll(C, 1)); tr3 = np.abs(L - np.roll(C, 1)) - tr = np.maximum.reduce([tr1, tr2, tr3]); tr[0] = tr1[0] - atr14 = ema(tr, period) - pdi = 100 * ema(up_dm, period) / (atr14 + 1e-9) - mdi = 100 * ema(down_dm, period) / (atr14 + 1e-9) - dx = 100 * np.abs(pdi - mdi) / (pdi + mdi + 1e-9) - adx = ema(dx, period) - return adx, pdi, mdi - -def supertrend(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=10, mult=3.0): - H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C) - if n == 0: return np.array([], float) - a = atr(H, L, C, period).copy() - hl2 = (H + L) / 2.0 - ub = hl2 + mult * a - lb = hl2 - mult * a - st = np.full(n, np.nan) - for i in range(1, n): - prev = st[i-1] if not np.isnan(st[i-1]) else hl2[i-1] - st[i] = ub[i] if C[i-1] <= prev else lb[i] - return st - -# ===== Pipeline: add_indicators ===== -def add_indicators(df: pd.DataFrame, min_bars: int = 200) -> pd.DataFrame: - if len(df) < min_bars: - raise ValueError(f"Too few bars: {len(df)} < {min_bars}") - C, H, L = df["close"].to_numpy(), df["high"].to_numpy(), df["low"].to_numpy() - df["ema20"] = ema(C, 20) - df["ema50"] = ema(C, 50) - df["rsi14"] = rsi(C, 14) - df["atr14"] = atr(H, L, C, 14) - m_line, m_sig, _ = macd(C) - df["macd"], df["macd_sig"] = m_line, m_sig - k, d = stoch_kd(H, L, C, 14, 3) - df["stoch_k"], df["stoch_d"] = k, d - mid, up, dn = bollinger(C, 20, 2.0) - df["bb_mid"], df["bb_up"], df["bb_dn"] = mid, up, dn - adx_, pdi, mdi = adx_val(H, L, C, 14) - df["adx"], df["pdi"], df["mdi"] = adx_, pdi, mdi - df["supertrend"] = supertrend(H, L, C, 10, 3.0) - return df diff --git a/new/new.zip b/new/new.zip deleted file mode 100644 index 125111e24eceacb5d20a8fef10211733e1065ad5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67567 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 S?2GvC+kbX!?Cxht%Kjg+gUitX diff --git a/new/portfolio.py b/new/portfolio.py deleted file mode 100644 index 32b711e..0000000 --- a/new/portfolio.py +++ /dev/null @@ -1,333 +0,0 @@ -# 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 deleted file mode 100644 index 57abe6e..0000000 --- a/new/portfolio_history.json +++ /dev/null @@ -1,79 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 01abac4..0000000 --- a/new/positions.json +++ /dev/null @@ -1,26 +0,0 @@ -[ - { - "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/static/app.js b/new/static/app.js deleted file mode 100644 index 26fc734..0000000 --- a/new/static/app.js +++ /dev/null @@ -1,221 +0,0 @@ -// 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/templates/index.html b/new/templates/index.html deleted file mode 100644 index da2a5a9..0000000 --- a/new/templates/index.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - Trading Dashboard - - - -
-

Trading Dashboard

-
- Ostatnia aktualizacja: — - -
-
- -
- -
-
-
Gotówka
-
-
-
-
Wartość konta (total)
-
-
-
-
Zysk (otwarte pozycje)
-
-
-
-
Otwarte pozycje
-
-
-
- - -
-

Wartość konta (total) — wykres

-
- -
-
- - -
-

Gotówka — wykres

-
- -
-
- - -
-

Otwarte pozycje

- - - - - - - - - - - - - -
TickerQtyEntrySideLast priceUnreal. PnLUnreal. PnL %
-
- - -
-

Ostatnie sygnały

- - - - - - - - - - - -
TickerTimePriceSignalInterval
-
- - -
-

Transakcje (ostatnie 50)

- - - - - - - - - - - - - - -
TimeActionTickerPriceQtyPnLPnL %Cash po
-
-
- - - - diff --git a/new/trade_log.json b/new/trade_log.json deleted file mode 100644 index 2a1d04a..0000000 --- a/new/trade_log.json +++ /dev/null @@ -1,28 +0,0 @@ -[ - { - "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/portfolio.py b/portfolio.py index cee0213..32b711e 100644 --- a/portfolio.py +++ b/portfolio.py @@ -1,11 +1,39 @@ # portfolio.py from __future__ import annotations import math, time, json -from typing import Dict, List, Any +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): - """Bezpieczne rzutowanie do typów akceptowalnych przez JSON.""" - if isinstance(obj, (float, int, str)) or obj is None: + """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()} @@ -18,16 +46,17 @@ def _to_native(obj: Any): 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) + json.dump(_to_native(data), f, ensure_ascii=False, indent=2, allow_nan=False) 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. + 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) @@ -35,103 +64,270 @@ class Portfolio: self.positions: Dict[str, Dict] = {} self.history: List[Dict] = [] self.trade_log: List[Dict] = [] - self.realized_pnl: float = 0.0 # skumulowany realized PnL + self.realized_pnl: float = 0.0 + self.last_prices: Dict[str, float] = {} # ostatnie znane ceny - def mark_to_market(self, prices: Dict[str, float]) -> float: - unreal = 0.0 + # ---------- 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(): - 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 + 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): + pnl_abs: float = 0.0, pnl_pct: float = 0.0, reason: Optional[str] = None): 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 + 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) + "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]): - """ - sigs: lista dictów/obiektów z polami: ticker, price, signal, time - BUY (1) / SELL (-1) / HOLD (0) - """ - clean = [] + # normalizacja + clean: List[Dict[str, Any]] = [] 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"))): + 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}) + 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"), - "equity": self.cash, - "cash": self.cash, - "open_positions": len(self.positions) + "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 - prices = {s["ticker"]: s["price"] for s in clean} - n = len(clean) - per_trade_cash = max(self.cash / (n * 2), 0.0) + # 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, price = s["ticker"], int(s["signal"]), float(s["price"]) - - # zamknięcie lub odwrócenie + 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"]: - qty = pos["qty"] - entry = pos["entry"] - side = 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 - 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] + # margin: brak zmiany cash przy short open - # 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) + 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) - 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) + "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)) }) - # zapis plików JSON + # 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/portfolio_history.json b/portfolio_history.json new file mode 100644 index 0000000..f552e93 --- /dev/null +++ b/portfolio_history.json @@ -0,0 +1,24 @@ +[ + { + "time": "2025-08-15 10:29:00+00:00", + "cash": 10000.0, + "positions_value": 2999.9999999999804, + "positions_net": -2999.9999999999804, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 1 + }, + { + "time": "2025-08-15 10:30:00+00:00", + "cash": 9996.027808030158, + "positions_value": 0.0, + "positions_net": 0.0, + "total_value": 9996.027808030158, + "equity_from_start": 9996.027808030158, + "unrealized_pnl": 0.0, + "realized_pnl_cum": -3.972191969841845, + "open_positions": 0 + } +] \ No newline at end of file diff --git a/positions.json b/positions.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/positions.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/pythonProject.zip b/pythonProject.zip deleted file mode 100644 index 9bf7ff511691759240e92bedb633719bfb970875..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 61134 zcmd_TU2J4Yb{7IU zd6{{Wyv6*Ryf=%LtSRld*X^@!m5X=q`z zqqKS0*bnjByiL+!m_`Riwz2JG6+3P!G?`ym>kwwEB9m}X!g6^ zI7z_7jX^YQfS+1XeYv)Taqq-~L3j`~_6K|*2$S}mFi9I}vKo-3cQMinc9;RWJGc+Z zJT`PXjFYrb?mGDSjUl@1m)j4A4PhbFwM>*}En;rpI82TY<4zRp3`dcn7ttH`N4-`f zT>>a_CaBbn%7#XKFXDrjo-he*4K#Wu655_+Fe2>R*uN6H)3FRy(iAefs z-z)^9v{@%*9I-6m!7zG=iX0l@-1NvJ4hsQWDHA6F^jmPd-;1E?TJnW2PVGpm-3U7! zMGBp2hG}`16-c7pU$Cgv;w0{s1(0;2a{vB95I%~N`ckEqKruDsak0-)C-Fzw_Qtgd z@TXU1XLr*hvYb|^rD5DDuPiNPY;7Tuy;06^`E(NXqT|K@g5}`t8n8MnSEcrSQc|G# zlw>~)64U^;RsKbB65IkmmCnL=P|U;rF*(nb2lEZ_Y?A+MBmITLh0a0~ShqWUj1CI# zmimLJSGr$W@Sh#V2ZzOHo&It0QM1!eB0Ma1#I`pVELVc);3PU4H6MgQzsXMzK6t-5 zZXO1`Fu1d|aXY~D)ra&9!;?HUED)#HYEV(^E6SEBcPU6cURNo{wWnAlDIGx zmkpSLuub+dX&y$cQ3q4*uoVr<)0<#5(LGVKrc*tH44;RCk$y-z`ZpbTbDeF>{_RDN zQj!zeyq+e(%MU|M10%o^cfz!&S{wlF9d=Ird(^n)i|@~=ofXu zK@zpVgWd!fRNc&&5h>|J(V&r_WH)?N#+<*rvV?*LfY*79Z7(giOLsB1LT=v*%8yN< z)!K6VNm8i=TjA&=3?3Vh`nYE8Aof>bnD-uRK~f%rv-}T!{2%`QKY~5ZpHIOo zS7X^)<}{I;V3s6u2CRDowH8MmYh>q5@=eSN2}HS=lv&ZhbFrMp}D$?MMA^)>VM`kgm7%*UO(Uvdw3%%`=VUo#KA z+PBtrZrZjt9TwNtEf$+MZ@*>h@7%eiAHDitx^>sQ{?ev-i#}M0Z``z)-`-d=1+{WE z;W{%_R$O4l(xp~O3t5xZy}^n}-{ckmt2*tY>N1R>rP`8~>Gb>13#uJfX_BMBioUXI z5C<4bLj!r*TEc6<0{0dcg}2(mT=3ynzaGH8!YUyhj=7lRA~%ltT#IlfhL|-lFL46H zV#MIMVmdHP;zlRx+2_j_?PM_rf$b3LhhP0`P@um@W}_s+BCgqLu$gE$4M+y!*4p~@B7m^< zh~3I4^Lb0RFda&V+rzM_vu##rIjW+I#F0JUAAvL>6qU@z=)xKp9hq z|F|Jf+yHU0WU>DOTohRU+2ZtgnB`#^v}=oN!_5KSlFlsmS*F&01cORGseU7DtYR@3 zc6Q+sCqK{i@BH@Gum1GR41azS^BgB8JIg)8|8$DWX+h>H=Xl~=yAX8x2L~7%_cU)L z8Ov$hj(cIRiM3x6jN8N>)quUJ%!Sb=;J`#J5lznY>Kkf6Ji1{ABh~01cA7AJS7}&Y zw+nyk7;+B`0ZXOQy$4aN2S-&i435Nl8V4;*40o_ZSqN@mQG)LDkFh4_!eb%$d6tQ4 z!JYl&>3gGt5V##jL)b%jcMBXF-=wLy5UjNxXq6h;P!5nKd4PqPEN`)rhq3^s6}n-T zCP&oAznmF%L<|!TF~c9AhTno4{wi|b>ZrJ)w~YBlCrs0*=V46G*~#(v=;?bA1OT5L z&Rjb~e`8pHEPMlEVT%7H>#y&9$O>WPXt9*j(V!ERRXLT}`33s1;3t9`r&+;2IEaSv zpj;|fN|o9$!a50!m0l{rv*kYs^Z^&G9#T!1s%!wuKI%+1*sO@Bq{*dfhK{#%fPf(mvq2keTe<@k|^)v=;?0-gE$%oj0w*R>FNQOrAMC_w zCt3&JD|!?_fO-tosv}^=bm~YLSKS=h0JB;ze4t~HoJ7HKIE=%dRfC=<)>NV=LI##C z*64eV^4^CISHexKmm!zFM~9vfsaRJ31DLBnAV>8}l~s1qw6)u$iCq#o%SJNlc=g3s zftrUg>mN$}d~FfWQ)&YF-yO{qSma#Q>tsNU?bdXv@jl~oy>QayxPG9XI$ z?0p<1Y1Gp16Cj;3I}NLcq7}i{_`u(jSQ*viqc!1N!-AS#`s3h1coaN{;1es0Bwmjn z#w}P!$x92tOWSF`c@%6@kdl|YCEdgbHtoy`_J(Z*!_H|@L$C$t2hlZoujO3u#pvlb zJ8`f-1Rg?8cnjh66_gw;s4+)RzdL>q92i3b9AZ?l2wYD2h*XwbYeL08!WB zu2;#qA0Ur|=(ra@2$J}NZ!82)zYE0&#YMH0q|iaoZ!MA%Lg1#ga;x789|QxAH2UJY zXoyI`>P66Gz6+wCp%S_R3SR_^u5raYo@l{=R?)#eq$`P#I~S`)lio_rnm!4QEG6B z27_W1XT156TiVb!5=U+UaDn?4;?&5@#HHFRXV@HD{e8?KC&8T?o9nt8Wo*4LD4zsC zayYI8ad2X-8t6!4n`G7mhbtznhhgs^D#L9iNWlPH_Ge>&DhvUPDa;M+e!28cc-V)j z1FI@Hp9E#DP|mj&&$lY3?{z%29mi+r8zm6r#D2?0Lk>kFRA#kN; z)>37qaoz5FSRi%#0dhCOE*3Nun6@s9A~XpL0eV8ePHS({8Z4BeD)(%!0Hi!sa0}EU z6O)3Nrg~r+M?{bc`e5VM*3LTw*Xyg|2&i)s5U=7KI*m+FK*7z@a)Qkpc zupxi2Z$<;TxHT~iD>~j`GSpnu$lnD*ikOvmB( zzXK7aav2-pRcNL+AJ>|tu=?9)W^Lqoa4auQlUR|(+}@#E3EVdW3^Uz|MOt6%yo>efRTvRUTFR7nzf%9rL zJTWpsE<{I-DAH{TJn6sb+oWnvE_AeH7 zaM>i!xtukN&~BL8P=~zlc89ABw}9=9x`Q$0us3ke@{3wsnkL<_9MWR}QwEOg;V^`s z-p(!1bHbY0J2P}U7E^97`++?67AziK2rZ3hEqF=eI^Wz+4un)AE7rZi>9+LFXKt1{R2MQW0@@%9#H@zj9f(Tp3u${ z71lwCHtgy25L?EmT(+yqNApLRS)=*#a@F6v2AaDujTz_6lh_M9ww?+2zRn!}`trr+ z=YW@&S7{wEyu*_J6dvGC42rbkc3a1mlO{~)ItE%rpMDjTI6jPqSlC}(T4*i6cLmaI z=3ZP`TBzz9C1iL&TbMn@xE?I%=wr#T=3gLcIzEK@C6&$~9@|)d}NI49Ea07p;oA{;VH3T9x zCCEi=P)8X$c_+E44SiW(UZEA*Kd!G_#s`VSs=sOt!H9c!91$aoP_?LIMomPYRZYh_ zF@i5pw9Q>Pv%3j^rZ(T&222GSb9gKDs%$&g6<1+F_Ok6D!{LIsvZK~V8EFhMQ0O8s z>9Bsm_R(leZ-A%DiZ{G6OisUeFBE@JgiBCasBB{#piMX7odOro?o*zF!i0xdKPUC& zN)2Dj6$W0(^kCf&^cNIi-FP-E?2k^uqOPOh%m?LljF4hU1z)zU*oRySWTHon03*!w zqj1TM89JzFn5i4$ zMe=07Jm_uU7W4wp@@HebWqbVbn=wdnPf3Kd;VhZnN< zo5lBv;b|Hd4RJXN1qdj#YOQX?3#V8ysVrYa6BC-13z|VI#w(R777(&pE3JY_jFtU) ztXrsHh<8?%7DMN>35*uWz*egPK%oVGewm;1LJJ-8K47H!3r zHIosF%7)@c<4%Zc7ke4?3z@u9EM;QGb_g~@95T6J!NU$mxqJhi*sLr<+6|ZP<0;>v zhX6Yt>f?@m3?g`sdnin+W*s9b`WNI9Esgyce58q~^(Q?bJu6i*s2X&LsEHU$8W-s{ zFqWNpZgvfTofK2Ztj_nGphx@si`ive;aq{2zwz;oN5yN6Bz;Kkh9?y@v=$OAcofIAf~O>|mT8_InqCYBzYu zcl{AT#Z-y`)WwW^(_0pu4 zK1ye!=3yVc?zd{eHlub|OXkUGO$DF(T%foG8mC^Got>R)9%9`PyiD_2kU-yN=W2`o zx6=U5&h`3-qd^{MaFuHa1b@See-Nx({oHc!xs0d`n*TT7`@u`Ui^v@QAZ~ufMw7qT zQm>D2z6VJkYlNwxTT}KoxdUqg*cm7gH(mIT3yx}-7giYkfa+Wz1N4;z&=kwm1>Asw6=zcvk3f z5xi;#h|(VHkIM*+qc0L$6q&G-kSI;e)eKJ=lqoX{!Aivsk?S=OI)ZgbKZ)gN5DLSygcT$O za@YJRak}Uu?z&ztF$%hLpHZSB3ioS0uoxO_~_#EgBRir!($p&fNhAsYl(W_M@4Ddz|p7?{8reD_bV8}=PD@uGobR_mcW=$ zJdfk_Fd$V=6HSkS&ERoDrEHeCy&LtKeGXo|G)mjm&zG<*p9F1B<#67qwctc7o9d8s zmFr$whb?OqVVE@I*a>h$7W{uL(9TY>BZ(ib$34R{HI6CbbiPr zE0YaMvS@i-XNqep^SevXmUXy8dNkn5hz&L>%y$XghIa&})|9xIOtQFo0lBSgnXhJa z@eV_&gULlZgVP{hrimuy4a;kN-{QIvl1=OvZDB66a?xH=%EX$tbfxbmBv^mqUl9`YhkPC227lNP7qT&>_XP;F34q2Z?YSM9dfH8^lCvE^?Mhh?=*oh6v4~6sZU*s&CSM+=#^;f^megfOm~!+98r7c; z?O;mi{L+~-V#<}`S=C{{pEaYh+ay1Sv|E+Ed0lY%?8sZs9s zOeSInDMB_xeap-*MvKY*Ii}w@Z8RiC(b{RoMobX2;Xb+;N@_OjH0OdzJ#9{Il7C5u zL3oPXt||_|A51*-%{U2t)Hw|!sUJMXXNV+`!aM$%3=xw0jQ4bWz%NEg5GyI*;qS_M z&x*wpddnr`53-LhHULl-kjiaIr&uW+c$M&FN~LrlJeLiQIdFUW=wNM@9n8#P6(&z; zW;D~W!0)r;tO;UTiRlCD&UhAZ$PRx_3@WW2N9oe&x*yJ z(dBOgTS?(6!5&O?{G)~w-t6Ab2rZoDMBE(cnO26oqgHr|GplJ$p^A7CaV>2mR8CA8 zy3>zuL{uhfVMfnHfkPsb1gYqE9+1(Vlz}f=MJF3UXj?eD7!K{#p#XS-C+Ckl{oa8+ zdu+K4(-3v2z(znbpk{=UK)eYk5?6f|7;H@Bz<5nqD?tc`=*Om^einCKB#cf5=)DnN zDi(66*Hx}T!5UnDnxQqe`LvKl_Ip6<#DkZOvz!M7EOYppOKR8;nG!9=kATW7K^mP( zVYX;`@iI&wG+vzmi@9InoS5BBR^q>c0iqA{@bVZE_#@g9lkm(qXvY;JP%-0RS=f+A zrq3|mw36fOj9)BvP~MX>3P`T;$Wta3^UhaA(nKSAB!?|!SaD#*37EXaq%Iuov4Ywm zO@u4~qz7Ui!hos9M(j^H6rc5il4H3H0Rb6EPh8nkLr$1|61;d6CJ5xtfictL1YZ^a zY42ol=hl@bd6Urs@K91yjh$dq(#MKtQDuV6A!F9G@LXbLUG{Q2u#|cb z%*q)?lR3o?uB9UL*W0GFES*=4nNgVO>;iZd&Q-3pi?1o1Sf-i|WCJ9ir6YK&LXmZA z0tfcnl@n8?*v_EWK>@Cl3T^1H5rqldR0_hqR+kSUV}I~)zzbiomWX@;Qsa&_X#crlw~ zEPhig8{343ps{MCN7EKpK891#DfCMLaj^%p)I%yW9qH0a;=c?S3=m?P^_QS*uPm*Von##-wgW-(a)jm)FM%`|14?+ zI549fveV6J0?6S4Ncmo8Zyiyx0`l^|ij}0h1fH$Ho8f02ljaNOBYKd|3)O$p6zk z36l|HY79;S1m}Qpu+z)lGe0j=7a$x#-?X9!2Ig3sluSd%SjwQlNe4~wbR=+?pVxy+ zz?~~tB7hk9)=3Ni;2t?n&^$q5X%$Dmk3+l!J>bNPIzjn35gOxESJ-Lw7XmpR&n>oL z2sm8p^pE3{2MALe;A|9jy$%GBB%}d0!ST2kV=Qq_a}TE&qQyNNN(oO8qsi`ZyJn2H z^YgdDN7t_dy6jFOtY_`byYut4+1abXz2oozxkPX*c79$t0_l{{jXrZwA+Hh#DgsZ) zFx7(F=yq}h7C@?&0r%}r;$xnj5Sl#w3jkTIRuL&O7rZg*9W{YrIXS|SkraGaH7v_) zDme1@3rKv@>UYQJ7LQ-721k;s1M6h?RFHt|<=_OzZh_d|o9p#A_A01>9f*~oHO&;1Y;DPSf1>~=t=bSy&h`oaU~>! z09y~S3H!k}2w+=tJgwTS3@-=Ux7Lv0Yw^-j1z6tQzFB5)_shZB&fTgGAo?JgAXc%* z2fUG+|Iq4w&_&>+=d&yQlVGLDoSlD1O9Y4wgNTOO z3(B3*zC6fcqxK+d5a>MtzgO|ZY#Rf71TQL>xDy{?n1xES&>Ih;X1X~0;n%x_@)Q!PECp0W2oU99ls07czxePF(l^8NkLT5NCeQ(ysBF=px&W?ki#E$~6N! z=t%O90!L?P_PoB@v0L9JpTBm9Q>Dl)jVu>6x^ zIL&OP8IDJB8UpuPUb7H$2Zt2y9CySz5hVu5VpJ2%2EPRr3pxd5i2Wimvn)d57a{A5 zlH@f-9{#y}gBg+`QMGC-a;1iuMDm;gP*bgKDc;byS}j=)*77!tSa_zu`15LeD}p3DlTyL&tXUCCoSyv|`c0f?p$J5Y>C!M~VfG zq2UKIwE$9vDAX%K?@EC2{mRb>IB_o|A|@sL%lO!Sx8)Phw8qd)Q8c zxa;y3qvQ!h|7eJV9FCvE0M?K>f3Sz3T>wBD6iBX_v0xuixl@EN3&_41_{xpD6|Cd& z0}`o!JVsJA;_|L2!{h{+HRK>34nyYVjDV^r%a$bBc0y_&_RL76?bn_!jIK`_wP5;& zF#5`#WMJZ~)z8d5a)ug$jAEcYI?61_TJY{MbO-tcdjN;wkAl6&3Ljf>HuKAqJxI77 zo({Wlut#f*CsCN!1!pIafJF1UXa<8l+lQPEuswO(N`1Tvt;{n52o?v0lNe{BcwC3) zbHK6YXv+eUImuy>2(Qd(0rInr&327>>cKX(H<`+j6oOjayN)y?%$-|S&j=ujuN!y_ zMkvGK<1pyk`FWsv?cO_kn2YzeH#Rrr!|gk_H@uRa#dN@eP!ll>FWRsBAxu>oF0kJ3 z-gQ9H1h}<4?<@vJInG0KF)6Nc0c*=BA`b!B21y5CjMDCe=;{z2sYI$$ip2l~*Z7Le zQsGukk6|DQ*seQ}vqQR?%{ue*XxrJ{Y7}98pVN8JN?NUGQR`D{SEJk3u(lO449)%^ zM3id?tEzZnpLRWDQgxUc-%OXfj^!0;n34Pl^Z7AMPSwV=6FHbLu!8F*91a7(JYt!w z<0yKn2@>|SmV_`da1fCvsJ^_nV89srEQ^#guug*ID&VPOLZ-A!E0+R9$ONKq@B|gnoMSkLw@Hh{u*opfxzr%G%Z%0n>QIo6E70h!im01R6la1m~7aH23{U z5FW=^7|07#op0il+`lq;Iaw zz3xSEH{*#o;P^szV|$pEjHApDT(=0lRoq*-1%OEdMN0$<@I*`y82lLOJL&QFzk_@! zp3wyN5g0(%Ud(Z4f+Of&-z`nc&uJ5RAvPEExK9HSItU97(b)n*>@3(KbfF`4r5zHK zT<&82T;Z=v{Ix8nvF>cRIBJ;i8&t^|e-t7!KQ|-J^H3ZQ zDheS>yomZZzGWx?I~dbDjR7FKy(wiS4I^8r0W0qxobzcv8TF#00815=SF1ofWe&2S z8V*umKgYqJG(?qQuRGBd6Daf6)51&{ZA$n2MmZ6p1O8giQ&3+-ZYVgR zAg$v0gtgv9XcD{z%hp2(#$ASI3|2Lv!79+zI$RB?gvrOa&Vt#Mn#(vfM$T|}S$vZP zLYgUJG0x~M6?pUW3n<-B(Q_>HnFLo#T@r7I6n#x!JU%Vbs&^oAeX z?HNWDX_f|7-g70(R{~;(up@`TDi}z6S%^2*U04@@LUf37W9lUM@Gt%Kvaz7df~tIt zxx{E3jf3MpoJ$YDvPL!*hwb1Of+f0&@EBR^k_T}O8%463Qd0>&x55Bgj69q8Wwcq%4hXS0vDYPpUJWz)1 zkgd6E!FAYACoo%KuQQ9=F?nA-%0XFg-C>6D!1Vf!eg__tv6yz+4haSG=@`Vq^#U@Q z;mOGLU+h~vr%;mRIaHX}AWPl}H;R+VHHVaU@zZabJ_u|aIX44kR0SqjI1s>#&M=_i z+J)hbEeD4T2*Fgw%vWc%DrG+F%G+Q+r?(ujc3`xV^DH$W6PzW~riA{H?H4ir)Kr5P zO7~gR^aRb#UJEFT+{K7%Bkv_JLh%{c@%YloJ`&cMQ6f!R&|AhKhjW;kkj||3v6FBN zlbpn(4DOSX<1W_IF_=>LkQ`tgx9BtI*T-DIoUEvmz>tr+E!#UzCUA|wH@k@Ij26vX z^LNqNjW|v=e2C59+1-0s*504J9wklWC1nhH{T6e(p1!L#yT)j#dM~1v>IheT43SlB zc6JxXU^;D)-`n1}JNq^YKnGn%(r3-vz9<&Y)9)UIi>>~m@zBn0Aj)u??(O>2 z_lxcnu<<`nFQto)*}If!fC@Xu;Wz zgH_T`9PG&m6QYu@nRdXRQMcc#4zW!bwq^@2ktb*$OjxaA1pz@Z(dmxH4{(?nK5~Mo zVjHgbKB%_U1h5N&9UOlHJWLLK=E+x$uiSER=^{)p33xOT!^M&E3;h*zn`lX{*RkHg z20Z;nnhBAX2(A*qxvwA_6Ogvz|Euvwe zNr?E&$lqJo1V{I{zp{fA!bh0L#HgUW%MKt6o<$_XISQNO2fguYM!ho4tHEE?rP^z- zo}i7G-4Qkgv;o)@>>?YX2 z9^Z4b>hV1{D<0o-v&ep&9A*3IU;Zig|Vw<&echade%H{V~c~i-g)yC&ZA=H#*&M;qo?+u4t?P8A?TfNJMxWcBN{TE z#Z^kX+l5?sbrNs!T&%HZ;7n6Na|TiT^a*kGOz*X{9HSX$O#Ej!ZLiJiqTGSK2c7e zmwNpw6%M!ue!eOOs=j10@#U*-4a0+O=;}xy#MFWtj|P~O5-n+AIF+1B)VDju51=tZY`Zg|czVKuTyG%z(s)>K>j3_%Y51XD6~vGqgrsG$C-(nCL=AfFfKqR6WT{9Fo zNosk#&ums1{rN_v&nknnMNYzH^u$ z?s7UJd~qrsh=TlBcmT~RZmL5xtMWXlf@cjrbNHlvpER_Y&HGc(>K}m=yiNkNVckZ8 z@BE;{G6xL|U4e+GEI5I2rowg}jaX{Yo?oFr5;;DETE!6@OD*p3Vc@L%PgQr!f@_by znK2ST*?}I}ClndeH31TbudQuu@(6wQN<;h>mRBy+E?&yLk(j;(-V%A*SUI9h=S}IX zhRS+FAxO&8Pp-QPF6-2P#r}!|#b-U~uRQ%k`m2y$psUY2dWasj_Jo{jE8bg5g8*Vw zK7{hAm*6htf3PmNvZ#L^CwRbBGy!tC6#hGydNVU`;k31UFu!;W3TK13xtLjri|0SR zTlrW2dgUk2Fp77 z(3O#hjCmBWH&wq%HH8C>xY?wKOtryRzGA*%3B@o6WSPe!)Z52Y6(_gD+hv>&d_5>( zN3RsDnm2fcL;P>Vk0K=OtGs6D$|WgRAB@lnTQDZ*Ud36gUTYMT*S2oLr?O>l=zxP3 z;j4`18bcq&FwcW~H)|GkD7SUQg2dof1XW<;fLI^Hy67L{Y&dcUW5%y(H8G=0hbhhp zE-o&wydvziyh^^(^HcVn+@C=nobqn@m6aN*t}NAX=bd>UfKw2c**~DS8HO~RFgPM} zUpm`CI3zmMxVDB1B$bkQ&Zo>i9<^?fGI-JEWl>V2J?hC$jifKxftim`F>HCE3^sES zmH3!F)NyO3LvD27io;-SA2MQ0%MLHi04!T9mXwN7YJnk-Lj7vM%E)1dYD}jj%}{M) zb4{w6+50gZIJ^>iRm4Aa2 z_u0pmJWoFRnD5OWcpRIZ5pv&@ORtxNuRcev!WZOGO6s3|d>tcR!$K3b!rnxKh(f&< zwvbalhS8tmzDi^?rk#R_I-~=BoY4j`R=7N+k6a=!@_9;kLqo)_jc>M4S!n^iRsKxW zsbIF`p=O~^)Tt3JNsgKXjc@jiTnBg?OENr1yAX$A|XQgHq zS33RyU9pf8vJL;0o)?8$a8Kh>iFAf6!0lsG3Vx&an+AP}1t9)({2~G#u{IB+Muz+e zd~tH*HbXm)CS*>;E}OV`^Fk|1BWyr39>{3eKjc%`xmlce?ll9aJq9qHuPO4-IKnzS znvZK4IC+uJg1(r!*-YR#j2B`UX0z&$!phW8R%Kyx0y(qo*eXzZEW+b3#@q;R^I;ik zitRkH3nDBHq0#CQyc7| z{A%Fo^F1HBr#dM@U{wA&1x+ZGeS&0{uo|jf#pj_}-CLkfKw%ykV19Qu!eD(6l=m$1 zU^;w|@Uiae7ZX&59hF{d40GYx2w+l&GWyt*$ad(!JmGVrw4OWseYbsW!$iGf$iV8)I#Vl@UCm3iS!j!GO6tPQT_978p`^ zvY^T_d|UJ^ar_ut+rlMDk10WaI7ZRx1oehOd2ij>2F2}TbAC<4P zn?F>Hl^HqMFT)BFcVPUG>pJMb33X@s#wnK0Z#+)IVE7H?s9I{ zMyh@F!*Z2!_ulQ>H*dcwidN8e-=( zZD>(9{*Ep>_^~9-&q~X75eIF_47Jg8%t!?0w|TXahU6%+1^phuq&$F{NI0>b-7mjn zj>i6r4J@tm-wPDSyn2hdx)H~QWjv3(@`eNATX}*=htEEi&*Hi$qKOWj=8-7bHYX7C zj9`{Q>TxC`Q*uDamJQvIGJ$UqAv>Z=;4~CDE|%0Xk6Tkc6=_}*57pUZq?Bv99In}8 zlFEi_`ja2%Fp%5w%G_##>I>xCDpZ&=*RATxE5Xvn-Mc6yPB_cYw&Zgt zIm5+YFFk>_`|RVwO2L{@rt+3@i z9B)TB4}jh0a%s+03{n<#)y4HAk8;j0WQ%1n_~>xT@y5=rO`s!AC9;XPHXm?d?qGW? zYS7{`+p&5Rk>)8>mmJZAPZH_f9n+?8F?((8smQRkD{1R09ET=${K=I?e4l*FK=&!{ z5Q~Zv>-7?XN=krYwDkb1a)!SzRbHS9beheG`XbdR0GizKMQS0-MWHxdpd^PK8Q7ul zrmr8yofZ;$TS9^bu~?m4I2WYHjo5?$oz25La_;jC2G-5&)412{e_LdqlXVTukI%OX zsD^BS*u&`Bt z*4C+Irt^JTlbzZEXNicNR$;oJJSQ{iKtG!u%?KBj`PsajA zju59AX+1Z~SbQ)OTLchYC1Zlo#j#&CCG9(9C#%Y=+jE2+gWMj^;iV_ARZXswD$Elfs6ql1*aD}bB z(tg_8Cij&puh%A#ZnS~02RK3Q= zcXKx0jC%S6(I&Y6>{#!RqMsbra6y=ms{-bt=wF1`WD}jYp`?5Rw8%1MKyO(W3W`r1 z1i2*w%;T{oTF5IWw1Zp=Nd(=%^ohCv!x=WW^W>>O?78ERQBLXb8nvS8^cm<()~EWr z(>r@Cir`h5p30R1vfX|b%v>)Bl6XR7*|*7kNp6s~kemY%6gXdrtXnNPkAkx?IDFD& zRnPOKlX`AzP!uipYGvOpn<(5CTCtDLkv*}IWG4|_O+IV!c>50b(_Ucxm|q~`m^3^< z*v3+t+s$TfGNa#S&l;HwWVe{dyW~jCiQPbAJ~akN$!-w0kj##=3xC)10UARSpWrZ+ z-EYEiDvfs1JtW!25_ObCOfjr#b@4a;>Mr7`_`?`|kP#;6B4YZn-fkwzDsLhaWn8Ty zoCLAXt8>w%=#^++2Bcc;ju7>^I(PZvD;Gb{XP92+AH#?Ks0#BDU0>B&sQgHHnoG;a zSVs2JY8%I+I&hThd6nuYUI?mmu}4*Xf;1@DON+3*z7X8)?;{iedz(WXNPyQ8C$Fm@ zLfn20!PP^=4Xwg(L8Jl_!OShS5y%t1CN$)wVm;v3Zlfe{!z~C%5rhDz;bV!)K8{-{ zQkX5{Fzx^d8dx~&M|des%_krKgk!9%^F~5*2D;>(9qrkb@IS&6QxfX&t&+edyEU8e%p&ek) z-$JPj7>=)oy{SM^p+p-AC-=E0-=^)?(=E!7y%RWahWm0{*VrvlrkT z9{tCSeY_hyOv~iSYCA@}6C9850hU)*Fu0g#+C$#a>WzoxWA+m~up;y(ftxsMnny~h zwS>l>Nn;sD1H>|08$`6Mob9he0gc*K5yELH6F9!gCgPu-|Mc$f|LBhn{wG``#UEan$|ayl zNhinE8Vx=4+-TTiXUGzS9S3ekBcfT#FsfUeo`{w;XVcsxcZDji3v*xOz#VVC>*2(Y zsbVrZ;^6>Shznq7vo>y+HwAEH98(379vtDcV=L@RiU!Ct(~(7t?2`PU2Rw_36VoG@ zG!e4MB9S!m{=rYfLqyy~?_*EbK%APU%Vgv-D7};5mP#hR@$C@nDx{Hx1=$%P zjR^A}EMmSg$uA>&T7)UAvSTMWN*driDXJnx$i}TT_=MoU;eklWjT#NJw98c{K@G0f z111sRaA8_ZnOAK-xEqx3;&TfJ)?stP1;ODlbseR#zT;K4iSPjr*6@DpR=Wbk zNt)upV=t$=3BIvIh`2KHg7#s^ zz~iR9J`MAmY}+&;ct8^tuvit)Vg$I3vU;;0bO8+NCh*=R47t^)q{mSfO6$zRSE}Ha zKUp?mTcz2LaM`tNH8(_!l!fhqDZ$=28!U5`;5g9@{vpukgvt>*16ddg0J@3a0`qyG zDz+Fuz0sT}SCCX~$cxP51Xr1t*LQKZVJ0c=2A>xgF_)l5oLIoB8&atBu6x|njxUC4 z;OOOx0p8$48E?ouWqN+6A)mcWPdjRkYpJJJ|yN0{KS!5LC>9X6o1%qQ|70J8ug zFR!58%L~D)7H;d&@8I}VREt!r$mC1<$ z(;L!8dt=p^>N-;zx=l)#L#S7p<-D{K9%!Iw%TK63+hrX56V;mOtg#spc@Dr-dt?b7 z9<(}L{TG3w8%1|%R91W6UG3?C-L%~%?CJk#S-?P7*Sd_>EvHY&zgwJArMW3HZMk3 zz8H)$G?oc0AsTwK5xK~a5U{D(KW@k}K}g}cEyL>d+kICKft;P%bFo45=*Xa0te>ar zs89`>c_60iO1~HGy22{jBsyMjy20_54zQhyJhplnt{%_)D)Mt+#p3?W;*<4ZB;E;4 zmTTSqUh->L-|&aEvvRVsm81bzJ_FY@?!t`%rVv2D#})oXI1z8_9&}3b1j}a$1-XK7 z-Y`Ide)!*Uu2e7cUi4luUq-$3FpiGtNG)LnEbh2^Nn8T;R>b^2Rr#{O84CWzYJyuP zqxy2mz)5+w>Z)uN4<)DR7EI|5qEX~IiFO5Z?h)KHojRhE;5tRbYJ|29;i#^c;7!dt zRV`d8DyRk;b^Q2L$LQ&PPlR%Li_2lu#j-VXhQViO3Fu?O30sAn+c&^ogFUt29(e1`X zx1cY+1{{tzYVX>bt23xgIt{p`y5mxrL935^RDbDo5!Odo!Y<_pY(2ZJg zh%;tA<)YQ#RXUR^j|1c4JH@F7aP-^(7l#eKb40l4?3|0D$ay1-|E>0l4wfad4uMY- z@p+3U{xHOm*;V+0;g*40D;OT^hvlUO{#RSN2=9a;hd9fCz**ihtPR9%mQ@n}m_yro zv@>sxVsG)RUG$j2w%e*<2>}p1XKB1CI1PN58b*LC1$iaYs#&5Sb3(X}+j53{kT^v4 zM{uR%W^ZoK<$Snyu*DZ+y;^gZFJE?5d1eF^PnC#i5@4X(piNX)vcWqqF84!q8fOl8 zs0rOzp#oc^1CSrMA)@N46+g70I95o>5Y%^M{VVjMU){u^tX06_XYY9sx9VQ#w}m6p zQo4#$+xaSnRIKFo&aZBB7xK!YzO%rKC9%DXg8jkSZs|26&^;Fn zo_MZ0pPIl$hGw<+-Y6={lK#7r4zJ>|NNqHP8FVOLvDFPRcVBOI#G&cFza7aP7Lj{k z>ERyWmJ|KFhz687*&v@;YtJGeN)s=f@-zjxePoBg@SMCklPoaoG>!0YXGi zqZ2&yNL}!pL|R8iL)Syi^k?Bzzb(^r|Zy z&cjU)Zf9X^&L>N5%TOUg>f&$npA^`4+U3DgY1)fIU7WfY3}C!9 z_6OQd_T4Kfy|&CNy?{6)_=Rd*Bn>nZhn`HL$?mG$5pi(|_cf$uLyonjCY`akWNP{l z0FF(3=PJliofNI*>g-^`0hzjk`=(B2mGB+~*qwTvYW7%KZ|Kyu>ZYAtH<#6RWL6`* zY|q$Xfkc@7K_hx7DTnOzSChzToA59zi**!JECSLS%r8Zb;Y($K4+2WR2`Extf6k=qEqS~*x+d}{(>?Saqfof6ubLn~*LfRQ&$_YvoW);QD4 zX&KEXYjK6h;HX%;UoPFeeRF3uIN!1=9}=$e$c(X882MS4`+RMsot^Zz(D0YLevZ{} zDun#yQr_!V3k3m}GAi8S;V?1eKjKv=nq^ zUBNI(4Xz3lC$FkXdPuN?uDQ=OwQrGa6(mnhXBkb2U9K-^ntN_p-HiJ-@7&pfrpHW< zz)m=far=ZWKEZ}bCyEB(JZ4TNI}t(L)sBY^n`f6zO#mY3!9+4}Q#xIf zKzNs~*4C9CVE4gBi4>~qYeXPL5_%-9)SzG8$!kwv5F|K)} zMT2GAHmQ-wD(%&f+!O%@^gyY5}O)F z-tgHbOY9*=Te~{FzzGXzgoSpd$UM@HM@p}__jGElY*bLmbaWaPpmPQlC)lTzNmv9J zW-0M(FG+@hyu=1`ekhn5+7>ek5d@uJZk3wn=4=Cra#56eBYzVu=(@eof(Ve1J|{SH z!EUgdCXKGUeH4BI_#FaIlne^HyRb0JDX%9mp*vr$+?RkoJf!;q_m)l(HM71m*KNwO zl2n>QRHZpFi8TP`HKY_jfuNe(GL)2@ge0wWmg}3c2v3HvqO4&d;uJ;0S1^9%;E7;( z`f5|N|42pL=18{YLdS3>!Fgj@kp2u6t!Syib{%Q-2zzca&HAB9y#H`?L~;2DJ<;^s z&*+2+IQGZEQ>#iOQI8}!!%}JKaJi`ytd2*4{+XPs_Lb2?nv5)cJK3vHNw9_C*D($f zFb%?U^-{??(xt_#GCWdDy&B4+AIJBJd%K6Y&i-RKP<_d6gsq1>W(03aD|)2D?ad;X zW1N*J-iS;WIdU{6mehQu(w!9-%9*07apVg!2URAFq8x(8U5H(>O!deJdT2&pc4Af> zA-X&>ESSFK?5SHeGPA2V*cJZs_^Z%DfpKiI7O+BYqV=d86@pX2R~I*}!M9Y?wxzLufuC|KhR5?d09>Qd~5MM5{pBk=~BZjISQa)1`vOr zf|#sehI*1zTv;$pCbDwn7YlAM2`$@i;5YumMY5G(>&~^C8+YFguCLwRFn8$jx-xUy zo|z*H!HD~ktrk-q6ACxvTX{D7eGdV*wEsHH_x*JLD>L5#1IVcZ=Yz z9_LOf*q7*#IbR^kHg`&aLEbo!VBX)awTAscFGSKFIezsby+xAVQ6e|qt@A7cPr~7F zB@<0|c+FHr2myYfMVOtEu(w}WT>1BtC5`A$nISR!s&pofIN0t_Iep;>65~jm7g}u* z&ZN&DdISKX(=9dVrSbat+T}J*_zZD72iSvH;qO$ZDM3^CC|Wq=f)C7dB~V}|;#>mA zC)4Tf(tW(mJ>g-BoDdS&9Pgx9du!+3+UCu7?|m`2aTEC|@8102mopv1V&D^KFwV!; zAQm@&z7Pw_07*PHL=aS!;T(*8hZc*SvdGh=npE4hJweoW4yg8zdHBi|PgxQw{~3L* zkWOV)mdrs#n;3d%b)1eXt9rJ*K=u+@LEfJrobC&D$JJPBpIT2aX|()(aMmgWT7tw>ieM`V=V}nPA_o@pNE43fSq2UnvusCz>ZujjrcVwz$gfYr zM?xT7Xirm-7c0TWo9}MCb#MKPYhox?le!4^*dm8VLG0XiUFpW1&Fh7iQ^t)hIG*O1G9wo>hqutj970MKCXfzd z@>LM%DZ+_^?0Y(xGN>70#D7gvxQh5g_P-Fw+>XmGPa;h7D{b033P7#sOn2wJE>lzi6v4aLtK5D! zc>A3%KK<+KH`i!D1=x{5NN-_m3Vgrj;G(mt=c_YJ1v(v93d{)2sBM@B*`gD zgplk9gJZi;12#hMfw|Y$o|mIs^566cLws?NdivS~G3m3;m?mNFVz3-tF7$x_3;gTm zK%;A{YZu-+arbGrwHK;~KEiA{zb49XJfXz z4z0(KVVY@J(AIMLb5aYbYZS3mprjwwq9k>csW}%aF|+@oFF(7Y17Q%s?4+la>$`G~ z6&Saqq%F}BvY`1l7Y-(x93f$h+%jF`k{9eQv706^O#T#xm$O3wGUdu-9NY#Iw*vT| z2cwjC@G3Z3(ux&7ogHx?DARIwn9zX6cOj1*2SJ4s3zW*+ z{fomppE{c)e+%IM(;xrV->iag{F%*x|5sIl2V-?QwdBwz_pklVkN?wW!R9djoA3SL zrQZdq`SVjb*q0UoyWfzDBMXSXfAh~jlP+Ha;=`}~%kTZ2ADfxsPag5r%b*qlx8i0} zfL*%r|Nf0Gtj4{K~)k4?ie-@Xz z|Hp6r^I!ix-004qJQrTNJu`E6W9|B_jas+mQ~X~qe&NlnXN>0Leeu5FyxN_-J>G9& z?RW0}?9V-;g~?mw{TALw3vQRp2mOox{?gxg`Bb``c0Z{F&6Ik7@z|x+^8wHN@1Oez z|L3WIRh>-UX=(un0Q|pYfT>j;BtH0W|I$m}TzLlg$pnBt_}}}M!`r0;^jmnI4oqe? z@>^*CDqC=Qj(p(%%fJ7 (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) + "%"); -// ── utils ────────────────────────────────────────────────────────────────────── -const fmtNum = (x, d = 2) => - 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) }; +async function getJSON(url) { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) throw new Error(`${url}: ${r.status}`); + return r.json(); } -function makeUrl(path) { - return `${API_BASE}${path}${path.includes("?") ? "&" : "?"}_ts=${Date.now()}`; -} +// 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); -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." - ); + 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); } -// ── 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); +async function loadAll() { 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(); - } -} + const [snap, hist, pos, trades] = await Promise.all([ + getJSON("/api/snapshot"), + getJSON("/api/history"), + getJSON("/api/positions"), + getJSON("/api/trades"), + ]); -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, + // 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); }); - 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 || "–"}`); + // 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"); - const roundEl = document.getElementById("roundNo"); - if (roundEl) roundEl.textContent = s.round ?? "–"; + // liczba otwartych pozycji + document.getElementById("open-pos").textContent = + Number(last?.open_positions ?? (pos?.length ?? 0)); - const cashEl = document.getElementById("cash"); - if (cashEl) cashEl.textContent = fmtNum(s.cash, 2); + // ===== 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); - // now-playing - const stageEl = document.getElementById("stage"); - if (stageEl) stageEl.textContent = (s.stage || "idle").toUpperCase(); + // ===== 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); - const tickerEl = document.getElementById("ticker"); - if (tickerEl) tickerEl.textContent = s.current_ticker || "–"; + // ===== 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); - const idx = s.current_index ?? 0; - const total = s.tickers_total ?? 0; + 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 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)} + ${fmt6(qty)} + ${fmt6(entry)} + ${side === 1 ? "LONG" : "SHORT"} + ${fmt6(price)} + ${fmt2(upnl)} + ${fmtPct(upct)} `; - tbody.appendChild(tr); - } - } catch (e) { - console.error("positions error:", e); - } -} + posBody.appendChild(tr); + }); -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) => { + // ===== 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 = ` - ${t.time ?? ""} - ${t.ticker ?? ""} - ${t.action ?? ""} - ${fmtNum(t.price)} - ${fmtNum(t.size, 0)} + ${s.ticker} + ${s.time} + ${fmt6(s.price)} + ${sigTxt} + ${s.interval} `; - tbody.appendChild(tr); + 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("trades error:", e); + console.error("loadAll error:", e); + document.getElementById("last-update").textContent = "Błąd ładowania danych"; } } -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."); - } -}); +document.getElementById("refresh-btn").addEventListener("click", loadAll); +loadAll(); +setInterval(loadAll, 1000); diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 17f4d82..0000000 --- a/static/style.css +++ /dev/null @@ -1,41 +0,0 @@ -: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/new/static/styles.css b/static/styles.css similarity index 100% rename from new/static/styles.css rename to static/styles.css diff --git a/new/strategies.py b/strategies.py similarity index 100% rename from new/strategies.py rename to strategies.py diff --git a/strategy.py b/strategy.py deleted file mode 100644 index bbb3c2b..0000000 --- a/strategy.py +++ /dev/null @@ -1,93 +0,0 @@ -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 index c55391a..da2a5a9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,81 +1,114 @@ - - - Trader – Panel - - + + + Trading Dashboard + -
- Loop: - - - - +
+

Trading Dashboard

+
+ Ostatnia aktualizacja: — + +
+
- | Auto-refresh: - - - +
+ +
+
+
Gotówka
+
+
+
+
Wartość konta (total)
+
+
+
+
Zysk (otwarte pozycje)
+
+
+
+
Otwarte pozycje
+
+
+
- | Runda: - | Gotówka: -
+ +
+

Wartość konta (total) — wykres

+
+ +
+
-
- Teraz: - - - 0 / 0 - - Ostatnia akcja: - -
+ +
+

Gotówka — wykres

+
+ +
+
-
-
-

Pozycje

- + +
+

Otwarte pozycje

+
- + + + + + + + + +
TickerStronaIlośćWejścieOstatniaPnL
TickerQtyEntrySideLast priceUnreal. PnLUnreal. PnL %
-
-
-

Transakcje (ostatnie 50)

- + + + +
+

Ostatnie sygnały

+
- + + + + + + +
CzasTickerAkcjaCenaIlość
TickerTimePriceSignalInterval
-
-
+ - + +
+

Transakcje (ostatnie 50)

+ + + + + + + + + + + + + + +
TimeActionTickerPriceQtyPnLPnL %Cash po
+
+ + + diff --git a/trade_log.json b/trade_log.json new file mode 100644 index 0000000..77badb8 --- /dev/null +++ b/trade_log.json @@ -0,0 +1,28 @@ +[ + { + "time": "2025-08-15 12:31:18", + "action": "SELL", + "ticker": "OP-USD", + "price": 0.7543854713439941, + "qty": 3976.7467878924763, + "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:32:18", + "action": "SELL", + "ticker": "OP-USD", + "price": 0.7553843259811401, + "qty": 3976.7467878924763, + "side": -1, + "pnl_abs": -3.972191969841845, + "pnl_pct": -0.0013240639899472903, + "realized_pnl_cum": -3.972191969841845, + "cash_after": 9996.027808030158, + "reason": "SIGNAL" + } +] \ No newline at end of file diff --git a/trader.py b/trader.py deleted file mode 100644 index 8c31d16..0000000 --- a/trader.py +++ /dev/null @@ -1,264 +0,0 @@ -# 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 diff --git a/new/trading_bot.py b/trading_bot.py similarity index 100% rename from new/trading_bot.py rename to trading_bot.py