From 861faf4ee400d069561ccb04c9f6e69e25f2b27b Mon Sep 17 00:00:00 2001 From: Patryk Zamorski Date: Fri, 15 Aug 2025 12:19:07 +0200 Subject: [PATCH] init --- .idea/.gitignore | 3 + .idea/inspectionProfiles/Project_Default.xml | 19 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 10 + .idea/modules.xml | 8 + .idea/pythonProject.iml | 10 + .idea/vcs.xml | 6 + README.md | 226 ++++++++++ app.py | 76 ++++ config.py | 57 +++ data.py | 84 ++++ indicators.py | 76 ++++ install.sh | 14 + io_utils.py | 37 ++ metrics.py | 26 ++ new/app.py | 61 +++ new/indicators.py | 125 ++++++ new/new.zip | Bin 0 -> 67567 bytes new/portfolio.py | 333 +++++++++++++++ new/portfolio_history.json | 79 ++++ new/positions.json | 26 ++ new/signals_scan.json | 344 ++++++++++++++++ new/snapshot.json | 385 ++++++++++++++++++ new/static/app.js | 221 ++++++++++ new/static/styles.css | 28 ++ new/strategies.py | 137 +++++++ new/templates/index.html | 114 ++++++ new/trade_log.json | 28 ++ new/trading_bot.py | 157 +++++++ portfolio.py | 137 +++++++ pythonProject.zip | Bin 0 -> 61134 bytes requirements.txt | 4 + server.py | 83 ++++ static/app.js | 253 ++++++++++++ static/style.css | 41 ++ strategy.py | 93 +++++ templates/index.html | 81 ++++ trader.py | 264 ++++++++++++ 38 files changed, 3652 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/pythonProject.iml create mode 100644 .idea/vcs.xml create mode 100644 README.md create mode 100644 app.py create mode 100644 config.py create mode 100644 data.py create mode 100644 indicators.py create mode 100644 install.sh create mode 100644 io_utils.py create mode 100644 metrics.py create mode 100644 new/app.py create mode 100644 new/indicators.py create mode 100644 new/new.zip create mode 100644 new/portfolio.py create mode 100644 new/portfolio_history.json create mode 100644 new/positions.json create mode 100644 new/signals_scan.json create mode 100644 new/snapshot.json create mode 100644 new/static/app.js create mode 100644 new/static/styles.css create mode 100644 new/strategies.py create mode 100644 new/templates/index.html create mode 100644 new/trade_log.json create mode 100644 new/trading_bot.py create mode 100644 portfolio.py create mode 100644 pythonProject.zip create mode 100644 requirements.txt create mode 100644 server.py create mode 100644 static/app.js create mode 100644 static/style.css create mode 100644 strategy.py create mode 100644 templates/index.html create mode 100644 trader.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..a63cecf --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,19 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8ed7134 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..e15ec35 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/pythonProject.iml b/.idea/pythonProject.iml new file mode 100644 index 0000000..2c80e12 --- /dev/null +++ b/.idea/pythonProject.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0780db --- /dev/null +++ b/README.md @@ -0,0 +1,226 @@ +# 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 new file mode 100644 index 0000000..89c8574 --- /dev/null +++ b/app.py @@ -0,0 +1,76 @@ +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 + +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) + +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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..c13491f --- /dev/null +++ b/config.py @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..4ba3ff1 --- /dev/null +++ b/data.py @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..0cc2e1c --- /dev/null +++ b/indicators.py @@ -0,0 +1,76 @@ +from __future__ import annotations +import math +from typing import Tuple +import numpy as np +import pandas as pd + +def sma(arr: np.ndarray, period: int) -> np.ndarray: + if len(arr) < period: return np.full(len(arr), 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) + 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) + 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] + 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): + if len(C) < period: return np.full(len(C), np.nan), np.full(len(C), 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): + s = pd.Series(arr) + ma = s.rollizng(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 + 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): + 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) + 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 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..b9b85b4 --- /dev/null +++ b/install.sh @@ -0,0 +1,14 @@ +#!/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 new file mode 100644 index 0000000..3f7a457 --- /dev/null +++ b/io_utils.py @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..7b55542 --- /dev/null +++ b/metrics.py @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..b855b43 --- /dev/null +++ b/new/app.py @@ -0,0 +1,61 @@ +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 new file mode 100644 index 0000000..6ebd7e4 --- /dev/null +++ b/new/indicators.py @@ -0,0 +1,125 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..125111e24eceacb5d20a8fef10211733e1065ad5 GIT binary patch literal 67567 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 S?2GvC+kbX!?Cxht%Kjg+gUitX literal 0 HcmV?d00001 diff --git a/new/portfolio.py b/new/portfolio.py new file mode 100644 index 0000000..32b711e --- /dev/null +++ b/new/portfolio.py @@ -0,0 +1,333 @@ +# portfolio.py +from __future__ import annotations +import math, time, json +from typing import Dict, List, Any, Optional + +# ========= TRYB KSIĘGOWOŚCI ========= +# "stock" – akcje (short dodaje gotówkę przy otwarciu, przy zamknięciu oddajesz notional) +# "margin" – FX/CFD (short NIE zmienia cash przy otwarciu; cash zmienia się o zrealizowany PnL przy zamknięciu) +ACCOUNTING_MODE = "margin" + +# ========= RYZYKO / ATR ========= +USE_ATR = True # jeśli sygnał ma "atr" > 0, to SL/TP liczone od ATR +SL_ATR_MULT = 2.0 # SL = 2.0 * ATR +TP_ATR_MULT = 3.0 # TP = 3.0 * ATR +RISK_FRACTION = 0.003 # 0.3% equity na trade (position sizing wg 1R) + +# fallback procentowy, gdy brak ATR +SL_PCT = 0.010 +TP_PCT = 0.020 +TRAIL_PCT = 0.020 # spokojniejszy trailing + +SYMBOL_OVERRIDES = { + # "EURUSD=X": {"SL_PCT": 0.0025, "TP_PCT": 0.0050, "TRAIL_PCT": 0.0030}, +} + +# ========= LIMITY BUDŻETOWE ========= +ALLOC_FRACTION = 0.25 # max % gotówki na JEDEN LONG +MIN_TRADE_CASH = 25.0 +MAX_NEW_POSITIONS_PER_CYCLE = 2 # None aby wyłączyć limit + +# ================================= +def _to_native(obj: Any): + """JSON-safe: NaN/Inf -> None; rekurencyjnie czyści struktury.""" + if isinstance(obj, float): + return None if (math.isnan(obj) or math.isinf(obj)) else obj + if isinstance(obj, (int, str)) or obj is None: + return obj + if isinstance(obj, dict): + return {k: _to_native(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_to_native(x) for x in obj] + try: + return float(obj) + except Exception: + return str(obj) + +def save_json(path: str, data: Any) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(_to_native(data), f, ensure_ascii=False, indent=2, allow_nan=False) + +class Portfolio: + """ + LONG open: cash -= qty*price ; close: cash += qty*price ; PnL = (px - entry)*qty + SHORT open (stock): cash += qty*price ; close: cash -= qty*price ; PnL = (entry - px)*qty + SHORT open (margin): cash bez zmian ; close: cash += PnL ; PnL = (entry - px)*qty + + total_value: + - stock -> cash + positions_net (wartość likwidacyjna) + - margin -> start_capital + realized_pnl + unrealized_pnl + """ + def __init__(self, capital: float): + self.cash = float(capital) + self.start_capital = float(capital) + self.positions: Dict[str, Dict] = {} + self.history: List[Dict] = [] + self.trade_log: List[Dict] = [] + self.realized_pnl: float = 0.0 + self.last_prices: Dict[str, float] = {} # ostatnie znane ceny + + # ---------- helpers ---------- + def _get_params(self, ticker: str): + o = SYMBOL_OVERRIDES.get(ticker, {}) + sl = float(o.get("SL_PCT", SL_PCT)) + tp = float(o.get("TP_PCT", TP_PCT)) + tr = float(o.get("TRAIL_PCT", TRAIL_PCT)) + return sl, tp, tr + + def _init_risk(self, ticker: str, entry: float, side: int, atr: Optional[float] = None): + """Zwraca: stop, take, trail_best, trail_stop, one_r.""" + sl_pct, tp_pct, trail_pct = self._get_params(ticker) + use_atr = USE_ATR and atr is not None and isinstance(atr, (int, float)) and atr > 0.0 + + if use_atr: + sl_dist = SL_ATR_MULT * float(atr) + tp_dist = TP_ATR_MULT * float(atr) + stop = entry - sl_dist if side == 1 else entry + sl_dist + take = entry + tp_dist if side == 1 else entry - tp_dist + one_r = sl_dist + else: + if side == 1: + stop = entry * (1.0 - sl_pct); take = entry * (1.0 + tp_pct) + else: + stop = entry * (1.0 + sl_pct); take = entry * (1.0 - tp_pct) + one_r = abs(entry - stop) + + trail_best = entry + trail_stop = stop + return stop, take, trail_best, trail_stop, max(one_r, 1e-8) + + def _update_trailing(self, ticker: str, pos: Dict, price: float): + """Breakeven po 1R + trailing procentowy.""" + _, _, trail_pct = self._get_params(ticker) + + # BE po 1R + if pos["side"] == 1 and not pos.get("be_done", False): + if price >= pos["entry"] + pos["one_r"]: + pos["stop"] = max(pos["stop"], pos["entry"]) + pos["be_done"] = True + elif pos["side"] == -1 and not pos.get("be_done", False): + if price <= pos["entry"] - pos["one_r"]: + pos["stop"] = min(pos["stop"], pos["entry"]) + pos["be_done"] = True + + # trailing + if pos["side"] == 1: + if price > pos["trail_best"]: + pos["trail_best"] = price + pos["trail_stop"] = pos["trail_best"] * (1.0 - trail_pct) + else: + if price < pos["trail_best"]: + pos["trail_best"] = price + pos["trail_stop"] = pos["trail_best"] * (1.0 + trail_pct) + + def _positions_values(self, prices: Optional[Dict[str, float]] = None) -> Dict[str, float]: + """Zwraca Σ|qty*px| oraz Σqty*px*side. Fallback ceny: prices -> self.last_prices -> entry.""" + gross = 0.0 + net = 0.0 + for t, p in self.positions.items(): + px = None + if prices is not None: + px = prices.get(t) + if px is None or (isinstance(px, float) and math.isnan(px)): + px = self.last_prices.get(t) + if px is None or (isinstance(px, float) and math.isnan(px)): + px = p["entry"] + gross += abs(p["qty"] * px) + net += p["qty"] * px * p["side"] + return {"positions_gross": gross, "positions_net": net} + + def unrealized_pnl(self, prices: Dict[str, float]) -> float: + """Σ side * (px - entry) * qty.""" + upnl = 0.0 + for t, p in self.positions.items(): + px = prices.get(t, self.last_prices.get(t, p["entry"])) + if px is None or (isinstance(px, float) and math.isnan(px)): px = p["entry"] + upnl += p["side"] * (px - p["entry"]) * p["qty"] + return upnl + + def equity_from_start(self, prices: Dict[str, float]) -> float: + return self.start_capital + self.realized_pnl + self.unrealized_pnl(prices) + + # ---------- zamknięcia / log ---------- + def _close_position(self, ticker: str, price: float, reason: str): + pos = self.positions.get(ticker) + if not pos: return + qty, entry, side = pos["qty"], pos["entry"], pos["side"] + + if side == 1: + self.cash += qty * price + pnl_abs = (price - entry) * qty + else: + pnl_abs = (entry - price) * qty + if ACCOUNTING_MODE == "stock": + self.cash -= qty * price + else: + self.cash += pnl_abs # margin: tylko PnL wpływa na cash + + denom = max(qty * entry, 1e-12) + pnl_pct = pnl_abs / denom + self.realized_pnl += pnl_abs + + self._log_trade("SELL", ticker, price, qty, side, pnl_abs, pnl_pct, reason=reason) + del self.positions[ticker] + + def _log_trade(self, action: str, ticker: str, price: float, qty: float, side: int, + pnl_abs: float = 0.0, pnl_pct: float = 0.0, reason: Optional[str] = None): + ts = time.strftime("%Y-%m-%d %H:%M:%S") + if action == "BUY": + print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={'LONG' if side==1 else 'SHORT'}, cash={self.cash:.2f}") + else: + r = f", reason={reason}" if reason else "" + print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), cash={self.cash:.2f}{r}") + self.trade_log.append({ + "time": ts, "action": action, "ticker": ticker, "price": float(price), + "qty": float(qty), "side": int(side), + "pnl_abs": float(pnl_abs), "pnl_pct": float(pnl_pct), + "realized_pnl_cum": float(self.realized_pnl), "cash_after": float(self.cash), + "reason": reason or "" + }) + + # ---------- główna aktualizacja ---------- + def on_signals(self, sigs: List[dict]): + # normalizacja + clean: List[Dict[str, Any]] = [] + for s in sigs: + if isinstance(s, dict): + px = s.get("price") + if s.get("error") is None and px is not None and not math.isnan(px): + clean.append(s) + else: + if getattr(s, "error", None) is None and not math.isnan(getattr(s, "price", float("nan"))): + clean.append({ + "ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time, + "atr": getattr(s, "atr", None) + }) + + # snapshot, gdy brak świeżych danych + if not clean: + vals = self._positions_values(None) + prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()} + if ACCOUNTING_MODE == "stock": + account_value = self.cash + vals["positions_net"] + else: + account_value = self.equity_from_start(prices_map) + unreal = self.unrealized_pnl(prices_map) + self.history.append({ + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "cash": float(self.cash), + "positions_value": float(vals["positions_gross"]), + "positions_net": float(vals["positions_net"]), + "total_value": float(account_value), + "equity_from_start": float(self.start_capital + self.realized_pnl + unreal), + "unrealized_pnl": float(unreal), + "realized_pnl_cum": float(self.realized_pnl), + "open_positions": int(len(self.positions)) + }) + save_json("portfolio_history.json", self.history) + return + + # mapy cen + prices = {s["ticker"]: float(s["price"]) for s in clean} + self.last_prices.update(prices) + + # 1) risk exits (SL/TP/TRAIL + BE) + to_close = [] + for t, pos in list(self.positions.items()): + price = prices.get(t) + if price is None or math.isnan(price): continue + self._update_trailing(t, pos, price) + if pos["side"] == 1: + if price <= pos["stop"]: to_close.append((t, "SL")) + elif price >= pos["take"]: to_close.append((t, "TP")) + elif price <= pos.get("trail_stop", -float("inf")): to_close.append((t, "TRAIL")) + else: + if price >= pos["stop"]: to_close.append((t, "SL")) + elif price <= pos["take"]: to_close.append((t, "TP")) + elif price >= pos.get("trail_stop", float("inf")): to_close.append((t, "TRAIL")) + for t, reason in to_close: + price = prices.get(t) + if price is not None and not math.isnan(price) and t in self.positions: + self._close_position(t, price, reason=reason) + + # 2) zamknięcie na przeciwny/HOLD + for s in clean: + t, sig = s["ticker"], int(s["signal"]) + price = float(s["price"]) + if t in self.positions: + pos = self.positions[t] + if sig == 0 or sig != pos["side"]: + self._close_position(t, price, reason="SIGNAL") + + # 3) otwarcia – ATR sizing + limity + candidates = [s for s in clean if s["ticker"] not in self.positions and int(s["signal"]) != 0] + if MAX_NEW_POSITIONS_PER_CYCLE and MAX_NEW_POSITIONS_PER_CYCLE > 0: + candidates = candidates[:MAX_NEW_POSITIONS_PER_CYCLE] + + for s in candidates: + t, sig, price = s["ticker"], int(s["signal"]), float(s["price"]) + atr = s.get("atr", None) + if price <= 0: continue + + stop, take, trail_best, trail_stop, one_r = self._init_risk(t, price, sig, atr) + + # equity teraz (dla sizingu ryzyka) + if ACCOUNTING_MODE == "margin": + equity_now = self.start_capital + self.realized_pnl + self.unrealized_pnl(self.last_prices) + else: + vals_tmp = self._positions_values(self.last_prices) + equity_now = self.cash + vals_tmp["positions_net"] + + risk_amount = max(1e-6, equity_now * RISK_FRACTION) + qty_risk = risk_amount / max(one_r, 1e-8) + + # limit gotówki dla LONG + per_trade_cash = max(MIN_TRADE_CASH, self.cash * ALLOC_FRACTION) + qty_cash = per_trade_cash / max(price, 1e-12) if sig == 1 else float("inf") + + qty = max(0.0, min(qty_risk, qty_cash)) + if qty <= 0: continue + + # księgowanie gotówki przy otwarciu + if sig == 1: + cost = qty * price + if self.cash < cost: continue + self.cash -= cost + else: + if ACCOUNTING_MODE == "stock": + self.cash += qty * price + # margin: brak zmiany cash przy short open + + self.positions[t] = { + "qty": qty, "entry": price, "side": sig, + "stop": stop, "take": take, + "trail_best": trail_best, "trail_stop": trail_stop, + "one_r": one_r, "be_done": False + } + self._log_trade("BUY" if sig == 1 else "SELL", t, price, qty, sig) + + # 4) snapshot na bieżących/ostatnich cenach + vals = self._positions_values(self.last_prices) + prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()} + if ACCOUNTING_MODE == "stock": + account_value = self.cash + vals["positions_net"] + else: + account_value = self.equity_from_start(prices_map) + unreal = self.unrealized_pnl(prices_map) + + self.history.append({ + "time": clean[0]["time"], + "cash": float(self.cash), + "positions_value": float(vals["positions_gross"]), + "positions_net": float(vals["positions_net"]), + "total_value": float(account_value), + "equity_from_start": float(self.start_capital + self.realized_pnl + unreal), + "unrealized_pnl": float(unreal), + "realized_pnl_cum": float(self.realized_pnl), + "open_positions": int(len(self.positions)) + }) + + # 5) zapisy + save_json("trade_log.json", self.trade_log) + save_json("portfolio_history.json", self.history) + save_json("positions.json", [{"ticker": t, **p} for t, p in self.positions.items()]) diff --git a/new/portfolio_history.json b/new/portfolio_history.json new file mode 100644 index 0000000..57abe6e --- /dev/null +++ b/new/portfolio_history.json @@ -0,0 +1,79 @@ +[ + { + "time": "2025-08-15 10:11:00+00:00", + "cash": 10000.0, + "positions_value": 0.0, + "positions_net": 0.0, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 0 + }, + { + "time": "2025-08-15 10:12:00+00:00", + "cash": 10000.0, + "positions_value": 0.0, + "positions_net": 0.0, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 0 + }, + { + "time": "2025-08-15 10:13:00+00:00", + "cash": 10000.0, + "positions_value": 0.0, + "positions_net": 0.0, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 0 + }, + { + "time": "2025-08-15 10:14:00+00:00", + "cash": 10000.0, + "positions_value": 0.0, + "positions_net": 0.0, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 0 + }, + { + "time": "2025-08-15 10:15:00+00:00", + "cash": 10000.0, + "positions_value": 0.0, + "positions_net": 0.0, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 0 + }, + { + "time": "2025-08-15 10:16:00+00:00", + "cash": 10000.0, + "positions_value": 5999.999999999975, + "positions_net": -5999.999999999975, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 2 + }, + { + "time": "2025-08-15 10:17:00+00:00", + "cash": 10000.0, + "positions_value": 5999.999999999975, + "positions_net": -5999.999999999975, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 2 + } +] \ No newline at end of file diff --git a/new/positions.json b/new/positions.json new file mode 100644 index 0000000..01abac4 --- /dev/null +++ b/new/positions.json @@ -0,0 +1,26 @@ +[ + { + "ticker": "OP-USD", + "qty": 3973.896468582607, + "entry": 0.7549265623092651, + "side": -1, + "stop": 0.7624758279323578, + "take": 0.7398280310630798, + "trail_best": 0.7549265623092651, + "trail_stop": 0.7624758279323578, + "one_r": 0.00754926562309266, + "be_done": false + }, + { + "ticker": "NEAR-USD", + "qty": 1076.5240904642392, + "entry": 2.7867467403411865, + "side": -1, + "stop": 2.8146142077445986, + "take": 2.731011805534363, + "trail_best": 2.7867467403411865, + "trail_stop": 2.8146142077445986, + "one_r": 0.02786746740341206, + "be_done": false + } +] \ No newline at end of file diff --git a/new/signals_scan.json b/new/signals_scan.json new file mode 100644 index 0000000..23a5472 --- /dev/null +++ b/new/signals_scan.json @@ -0,0 +1,344 @@ +[ + { + "ticker": "GBPUSD=X", + "time": "2025-08-15 10:17:00+00:00", + "price": 1.3561896085739136, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURUSD=X", + "time": "2025-08-15 10:17:00+00:00", + "price": 1.1691803932189941, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "CHFJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 182.3179931640625, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "DOGE-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 0.2317201942205429, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "OP-USD", + "time": "2025-08-15 10:11:00+00:00", + "price": 0.7549265623092651, + "signal": -1, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "BTC-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 118967.421875, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "NZDUSD=X", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.5927330851554871, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURAUD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.7958300113677979, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "USDCHF=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.8054800033569336, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "SOL-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 195.40365600585938, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "USDJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 146.86399841308594, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "GBPJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 199.14500427246094, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "XLM-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.42842021584510803, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURGBP=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.8619400262832642, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "LTC-USD", + "time": "2025-08-15 10:14:00+00:00", + "price": 121.15275573730469, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "USDCAD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.3791099786758423, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "BNB-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 844.4680786132812, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ETH-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 4635.28955078125, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ETC-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 22.40416717529297, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "CADJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 106.49600219726562, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "TRX-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.3588825762271881, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 171.65899658203125, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "TON-USD", + "time": "2025-08-15 10:11:00+00:00", + "price": 0.01708882860839367, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ADA-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.951282799243927, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "NZDJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 87.01699829101562, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "AUDJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 95.58399963378906, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURCAD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.611799955368042, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "BCH-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 596.3115844726562, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "DOT-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 4.005911827087402, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ATOM-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 4.517627239227295, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "AUDNZD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.0983599424362183, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "GC=F", + "time": "2025-08-15 10:08:00+00:00", + "price": 3386.10009765625, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "NEAR-USD", + "time": "2025-08-15 10:11:00+00:00", + "price": 2.7867467403411865, + "signal": -1, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "GBPCAD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.8698300123214722, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "XRP-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 3.111118793487549, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURCHF=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.9409899711608887, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "LINK-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 22.371389389038086, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "AUDUSD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.6509992480278015, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + } +] \ No newline at end of file diff --git a/new/snapshot.json b/new/snapshot.json new file mode 100644 index 0000000..f97cac6 --- /dev/null +++ b/new/snapshot.json @@ -0,0 +1,385 @@ +{ + "time": "2025-08-15 12:19:02", + "last_history": { + "time": "2025-08-15 10:17:00+00:00", + "cash": 10000.0, + "positions_value": 5999.999999999975, + "positions_net": -5999.999999999975, + "total_value": 10000.0, + "equity_from_start": 10000.0, + "unrealized_pnl": 0.0, + "realized_pnl_cum": 0.0, + "open_positions": 2 + }, + "positions": [ + { + "ticker": "OP-USD", + "qty": 3973.896468582607, + "entry": 0.7549265623092651, + "side": -1, + "stop": 0.7624758279323578, + "take": 0.7398280310630798, + "trail_best": 0.7549265623092651, + "trail_stop": 0.7624758279323578, + "one_r": 0.00754926562309266, + "be_done": false + }, + { + "ticker": "NEAR-USD", + "qty": 1076.5240904642392, + "entry": 2.7867467403411865, + "side": -1, + "stop": 2.8146142077445986, + "take": 2.731011805534363, + "trail_best": 2.7867467403411865, + "trail_stop": 2.8146142077445986, + "one_r": 0.02786746740341206, + "be_done": false + } + ], + "signals": [ + { + "ticker": "GBPUSD=X", + "time": "2025-08-15 10:17:00+00:00", + "price": 1.3561896085739136, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURUSD=X", + "time": "2025-08-15 10:17:00+00:00", + "price": 1.1691803932189941, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "CHFJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 182.3179931640625, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "DOGE-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 0.2317201942205429, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "OP-USD", + "time": "2025-08-15 10:11:00+00:00", + "price": 0.7549265623092651, + "signal": -1, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "BTC-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 118967.421875, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "NZDUSD=X", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.5927330851554871, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURAUD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.7958300113677979, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "USDCHF=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.8054800033569336, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "SOL-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 195.40365600585938, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "USDJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 146.86399841308594, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "GBPJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 199.14500427246094, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "XLM-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.42842021584510803, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURGBP=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.8619400262832642, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "LTC-USD", + "time": "2025-08-15 10:14:00+00:00", + "price": 121.15275573730469, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "USDCAD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.3791099786758423, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "BNB-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 844.4680786132812, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ETH-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 4635.28955078125, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ETC-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 22.40416717529297, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "CADJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 106.49600219726562, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "TRX-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.3588825762271881, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 171.65899658203125, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "TON-USD", + "time": "2025-08-15 10:11:00+00:00", + "price": 0.01708882860839367, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ADA-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 0.951282799243927, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "NZDJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 87.01699829101562, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "AUDJPY=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 95.58399963378906, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURCAD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.611799955368042, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "BCH-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 596.3115844726562, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "DOT-USD", + "time": "2025-08-15 10:15:00+00:00", + "price": 4.005911827087402, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "ATOM-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 4.517627239227295, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "AUDNZD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.0983599424362183, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "GC=F", + "time": "2025-08-15 10:08:00+00:00", + "price": 3386.10009765625, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "NEAR-USD", + "time": "2025-08-15 10:11:00+00:00", + "price": 2.7867467403411865, + "signal": -1, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "GBPCAD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 1.8698300123214722, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "XRP-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 3.111118793487549, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "EURCHF=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.9409899711608887, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "LINK-USD", + "time": "2025-08-15 10:16:00+00:00", + "price": 22.371389389038086, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + }, + { + "ticker": "AUDUSD=X", + "time": "2025-08-15 10:18:00+00:00", + "price": 0.6509992480278015, + "signal": 0, + "period": "7d", + "interval": "1m", + "error": null + } + ], + "capital_start": 10000.0 +} \ No newline at end of file diff --git a/new/static/app.js b/new/static/app.js new file mode 100644 index 0000000..26fc734 --- /dev/null +++ b/new/static/app.js @@ -0,0 +1,221 @@ +// static/app.js +const fmt2 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(2)); +const fmt6 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(6)); +const fmtPct= (x) => (x == null || isNaN(x) ? "—" : (Number(x) * 100).toFixed(2) + "%"); + +async function getJSON(url) { + const r = await fetch(url, { cache: "no-store" }); + if (!r.ok) throw new Error(`${url}: ${r.status}`); + return r.json(); +} + +// prosty wykres linii na canvas (bez bibliotek) +function drawLineChart(canvas, points) { + const ctx = canvas.getContext("2d"); + const pad = 32; + const w = canvas.width, h = canvas.height; + ctx.clearRect(0, 0, w, h); + + if (!points || points.length === 0) { + ctx.fillStyle = "#9aa3b2"; + ctx.fillText("Brak danych", 10, 20); + return; + } + + const n = points.length; + const ys = points.map(p => p.y); + const minY = Math.min(...ys), maxY = Math.max(...ys); + const yLo = minY === maxY ? minY - 1 : minY; + const yHi = minY === maxY ? maxY + 1 : maxY; + + const x0 = pad, y0 = h - pad, x1 = w - pad, y1 = pad; + const xScale = (i) => x0 + (i / (n - 1)) * (x1 - x0); + const yScale = (y) => y0 - ((y - yLo) / (yHi - yLo)) * (y0 - y1); + + // osie + ctx.strokeStyle = "#242a36"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x0, y0); ctx.lineTo(x1, y0); + ctx.moveTo(x0, y0); ctx.lineTo(x0, y1); + ctx.stroke(); + + // siatka + ctx.strokeStyle = "#1b2130"; + [0.25, 0.5, 0.75].forEach(f => { + const yy = y0 - (y0 - y1) * f; + ctx.beginPath(); + ctx.moveTo(x0, yy); ctx.lineTo(x1, yy); + ctx.stroke(); + }); + + // podpisy min/max + ctx.fillStyle = "#9aa3b2"; + ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial"; + ctx.fillText(fmt2(yHi), 6, y1 + 10); + ctx.fillText(fmt2(yLo), 6, y0 - 2); + + // linia + ctx.strokeStyle = "#4da3ff"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(xScale(0), yScale(ys[0])); + for (let i = 1; i < n; i++) ctx.lineTo(xScale(i), yScale(ys[i])); + ctx.stroke(); + + // kropka na końcu + const lastX = xScale(n - 1), lastY = yScale(ys[n - 1]); + ctx.fillStyle = "#e7e9ee"; + ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill(); + ctx.fillText(fmt2(ys[n - 1]), lastX + 6, lastY - 6); +} + +async function loadAll() { + try { + const [snap, hist, pos, trades] = await Promise.all([ + getJSON("/api/snapshot"), + getJSON("/api/history"), + getJSON("/api/positions"), + getJSON("/api/trades"), + ]); + + // czas + document.getElementById("last-update").textContent = + "Ostatnia aktualizacja: " + (snap?.last_history?.time || "—"); + + // ===== ostatni wiersz historii ===== + const last = Array.isArray(hist) && hist.length ? hist[hist.length - 1] : null; + + const cashVal = Number(last?.cash ?? 0); + const totalVal = Number( + last?.total_value ?? + (Number(last?.cash ?? 0) + Number(last?.positions_net ?? 0)) // fallback dla starszych logów + ); + + // KARTY + document.getElementById("cash").textContent = fmt2(cashVal); + document.getElementById("total-value").textContent = fmt2(totalVal); + + // mapa ostatnich cen do liczenia zysku (unrealized) + const lastPrice = new Map(); + (snap?.signals || []).forEach(s => { + const px = Number(s.price); + if (!isNaN(px)) lastPrice.set(s.ticker, px); + }); + + // Zysk = suma niezrealizowanych PnL na otwartych pozycjach + let unrealPnL = 0; + (pos || []).forEach(p => { + const price = Number(lastPrice.get(p.ticker)); + const entry = Number(p.entry); + const qty = Number(p.qty); + const side = Number(p.side); + if (isNaN(price) || isNaN(entry) || isNaN(qty) || isNaN(side)) return; + unrealPnL += side === 1 ? (price - entry) * qty : (entry - price) * qty; + }); + const unrlEl = document.getElementById("unrealized"); + unrlEl.textContent = fmt2(unrealPnL); + unrlEl.classList.remove("pnl-positive", "pnl-negative"); + if (unrealPnL > 0) unrlEl.classList.add("pnl-positive"); + else if (unrealPnL < 0) unrlEl.classList.add("pnl-negative"); + + // liczba otwartych pozycji + document.getElementById("open-pos").textContent = + Number(last?.open_positions ?? (pos?.length ?? 0)); + + // ===== WYKRES WARTOŚCI KONTA (TOTAL) ===== + const totalCanvas = document.getElementById("totalChart"); + const totalPoints = (hist || []) + .map((row, i) => { + const v = (row.total_value != null) + ? Number(row.total_value) + : Number(row.cash ?? 0) + Number(row.positions_net ?? 0); + return { x: i, y: v }; + }) + .filter(p => !isNaN(p.y)) + .slice(-500); + drawLineChart(totalCanvas, totalPoints); + + // ===== WYKRES GOTÓWKI (CASH) ===== + const cashCanvas = document.getElementById("cashChart"); + const cashPoints = (hist || []) + .map((row, i) => ({ x: i, y: Number(row.cash ?? NaN) })) + .filter(p => !isNaN(p.y)) + .slice(-500); + drawLineChart(cashCanvas, cashPoints); + + // ===== POZYCJE ===== + const posBody = document.querySelector("#positions-table tbody"); + posBody.innerHTML = ""; + (pos || []).forEach((p) => { + const price = Number(lastPrice.get(p.ticker)); + const entry = Number(p.entry); + const qty = Number(p.qty); + const side = Number(p.side); + + let upnl = NaN, upct = NaN; + if (!isNaN(price) && !isNaN(entry) && !isNaN(qty) && !isNaN(side)) { + upnl = side === 1 ? (price - entry) * qty : (entry - price) * qty; + const denom = Math.max(qty * entry, 1e-12); + upct = upnl / denom; + } + const pnlClass = upnl > 0 ? "pnl-positive" : (upnl < 0 ? "pnl-negative" : ""); + + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${p.ticker} + ${fmt6(qty)} + ${fmt6(entry)} + ${side === 1 ? "LONG" : "SHORT"} + ${fmt6(price)} + ${fmt2(upnl)} + ${fmtPct(upct)} + `; + posBody.appendChild(tr); + }); + + // ===== SYGNAŁY ===== + const sigBody = document.querySelector("#signals-table tbody"); + sigBody.innerHTML = ""; + const signals = (snap?.signals || []).slice().sort((a,b)=>a.ticker.localeCompare(b.ticker)); + signals.forEach((s) => { + const sigTxt = s.signal === 1 ? "BUY" : (s.signal === -1 ? "SELL" : "HOLD"); + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${s.ticker} + ${s.time} + ${fmt6(s.price)} + ${sigTxt} + ${s.interval} + `; + sigBody.appendChild(tr); + }); + + // ===== TRANSAKCJE ===== + const tradesBody = document.querySelector("#trades-table tbody"); + tradesBody.innerHTML = ""; + (trades || []).slice(-50).reverse().forEach((t) => { + const pnlClass = t.pnl_abs > 0 ? "pnl-positive" : (t.pnl_abs < 0 ? "pnl-negative" : ""); + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${t.time} + ${t.action} + ${t.ticker} + ${fmt6(t.price)} + ${fmt6(t.qty)} + ${fmt2(t.pnl_abs)} + ${fmtPct(t.pnl_pct)} + ${fmt2(t.cash_after)} + `; + tradesBody.appendChild(tr); + }); + + } catch (e) { + console.error("loadAll error:", e); + document.getElementById("last-update").textContent = "Błąd ładowania danych"; + } +} + +document.getElementById("refresh-btn").addEventListener("click", loadAll); +loadAll(); +setInterval(loadAll, 1000); diff --git a/new/static/styles.css b/new/static/styles.css new file mode 100644 index 0000000..a951116 --- /dev/null +++ b/new/static/styles.css @@ -0,0 +1,28 @@ +/* static/styles.css */ +:root { --bg:#0f1115; --panel:#171a21; --text:#e7e9ee; --muted:#9aa3b2; --buy:#1fbf75; --sell:#ff4d4f; } +* { box-sizing: border-box; } +body { margin:0; background:var(--bg); color:var(--text); font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; } +header { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; background:var(--panel); border-bottom:1px solid #242a36; } +h1 { margin:0; font-size:18px; } +.meta { display:flex; gap:12px; align-items:center; color:var(--muted); } +button { background:#2a3242; color:var(--text); border:1px solid #2f3749; padding:6px 10px; border-radius:6px; cursor:pointer; } +main { padding:16px 20px; } +.cards { display:grid; grid-template-columns:repeat(4, minmax(160px, 1fr)); gap:12px; margin-bottom:14px; } +.card { background:var(--panel); border:1px solid #242a36; border-radius:10px; padding:12px; } +.card-title { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.06em; margin-bottom:6px; } +.card-value { font-size:20px; font-weight:600; } +section { margin-top:18px; } +table { width:100%; border-collapse:separate; border-spacing:0; background:var(--panel); border:1px solid #242a36; border-radius:10px; overflow:hidden; } +thead th { text-align:left; color:var(--muted); font-weight:600; padding:10px 12px; background:#141821; } +tbody td { padding:9px 12px; border-top:1px solid #202637; } +td.BUY, .BUY { color:var(--buy); font-weight:600; } +td.SELL, .SELL { color:var(--sell); font-weight:600; } +td.HOLD, .HOLD { color:#c8cbd2; } +/* PnL kolory */ +.pnl-positive { color: var(--buy); font-weight: 600; } +.pnl-negative { color: var(--sell); font-weight: 600; } +.BUY { color: var(--buy); font-weight: 600; } +.SELL { color: var(--sell); font-weight: 600; } +.HOLD { color: #c8cbd2; } +.chart-wrap { background: var(--panel); border:1px solid #242a36; border-radius:10px; padding:10px; margin-bottom:16px; } + diff --git a/new/strategies.py b/new/strategies.py new file mode 100644 index 0000000..37cc4ae --- /dev/null +++ b/new/strategies.py @@ -0,0 +1,137 @@ +# strategies.py +from __future__ import annotations +import numpy as np +import pandas as pd + +# ===== PRESET ===== +BUY_TH = 0.55 +SELL_TH = -0.55 +REQUIRE_TREND_CONFLUENCE = True # EMA50 vs EMA200 musi się zgadzać z kierunkiem +USE_ADX_IN_FILTER = True # używaj ADX w filtrze kierunku +TREND_FILTER_ADX_MIN = 18.0 +MTF_CONFLICT_DAMP = 0.6 # konflikt 1m vs 15m – osłab wynik + +# ===== FILTRY DODATKOWE ===== +SESSION_FILTER = True +SESS_UTC_START = 6 # handluj tylko 06:00–21:00 UTC (LON+NY) +SESS_UTC_END = 21 + +VOL_FILTER = True +MIN_ATR_PCT = 0.0005 # min ATR/price (0.05%) +MAX_ATR_PCT = 0.02 # max ATR/price (2%) – unikaj paniki +OVEREXT_COEF = 1.2 # nie wchodź, jeśli |close-EMA20| > 1.2*ATR + +def _resample_ohlc(df: pd.DataFrame, rule: str = "15T") -> pd.DataFrame: + o = {"open":"first","high":"max","low":"min","close":"last"} + return df[["open","high","low","close"]].resample(rule).agg(o).dropna(how="any") + +def _safe(df: pd.DataFrame, col: str, default=None): + return float(df[col].iloc[-1]) if col in df.columns else (float(default) if default is not None else None) + +def _ema(series: pd.Series, span: int) -> float: + return float(series.ewm(span=span, adjust=False).mean().iloc[-1]) + +def _atr_pct(df: pd.DataFrame, n: int = 14) -> float: + if "atr" in df.columns: + atr = float(df["atr"].iloc[-1]) + else: + h, l, c = df["high"], df["low"], df["close"] + prev_c = c.shift(1) + tr = pd.concat([h - l, (h - prev_c).abs(), (l - prev_c).abs()], axis=1).max(axis=1) + atr = float(tr.ewm(span=n, adjust=False).mean().iloc[-1]) + price = float(df["close"].iloc[-1]) + return 0.0 if price <= 0 else atr / price + +def _in_session(df: pd.DataFrame) -> bool: + ts = pd.Timestamp(df.index[-1]) + try: + hour = ts.tz_convert("UTC").hour if ts.tzinfo else ts.hour + except Exception: + hour = ts.hour + return SESS_UTC_START <= hour <= SESS_UTC_END + +def _trend_score_1m(df: pd.DataFrame) -> float: + s = 0.0 + ema20 = _safe(df, "ema20", _ema(df["close"], 20)) + ema50 = _safe(df, "ema50", _ema(df["close"], 50)) + s += 0.5 if ema20 > ema50 else -0.5 + + macd = _safe(df, "macd", _ema(df["close"],12) - _ema(df["close"],26)) + macd_sig = _safe(df, "macd_sig", macd) + s += 0.3 if macd > macd_sig else -0.3 + + adx = _safe(df, "adx", None) + if adx is not None: + if adx >= 18: s += 0.2 + elif adx <= 12: s -= 0.1 + + c = float(df["close"].iloc[-1]) + st = _safe(df, "supertrend", c) + s += 0.2 if c > st else -0.2 + + up = _safe(df, "bb_up", c + 1e9); dn = _safe(df, "bb_dn", c - 1e9) + if c > up: s += 0.2 + if c < dn: s -= 0.2 + + rsi = _safe(df, "rsi14", 50.0) + if rsi >= 70: s -= 0.2 + if rsi <= 30: s += 0.2 + return s + +def _trend_score_15m(df_1m: pd.DataFrame) -> float: + try: + htf = _resample_ohlc(df_1m, "15T") + if len(htf) < 30: return 0.0 + close = htf["close"] + ema20 = close.ewm(span=20, adjust=False).mean().iloc[-1] + ema50 = close.ewm(span=50, adjust=False).mean().iloc[-1] + macd_line = close.ewm(span=12, adjust=False).mean().iloc[-1] - close.ewm(span=26, adjust=False).mean().iloc[-1] + macd_sig = pd.Series(close).ewm(span=9, adjust=False).mean().iloc[-1] + s = (0.6 if ema20 > ema50 else -0.6) + (0.4 if macd_line > macd_sig else -0.4) + return float(s) + except Exception: + return 0.0 + +def _hysteresis_map(score: float) -> int: + if score >= BUY_TH: return 1 + if score <= SELL_TH: return -1 + return 0 + +def get_signal(df: pd.DataFrame) -> int: + if len(df) < 200: + return 0 + + # Filtry ex-ante + if SESSION_FILTER and not _in_session(df): + return 0 + atrp = _atr_pct(df) + if VOL_FILTER and not (MIN_ATR_PCT <= atrp <= MAX_ATR_PCT): + return 0 + + s1 = _trend_score_1m(df) + s15 = _trend_score_15m(df) + score = 0.7 * s1 + 0.3 * s15 + if (s1 > 0 and s15 < 0) or (s1 < 0 and s15 > 0): + score *= MTF_CONFLICT_DAMP + sig = _hysteresis_map(score) + + # Nie handluj pod prąd + nie gonić świecy + if sig != 0 and REQUIRE_TREND_CONFLUENCE: + ema50 = _safe(df, "ema50", _ema(df["close"], 50)) + ema200 = _safe(df, "ema200", _ema(df["close"], 200)) + adx = _safe(df, "adx", None) + long_ok = (ema50 > ema200) and (not USE_ADX_IN_FILTER or adx is None or adx >= TREND_FILTER_ADX_MIN) + short_ok = (ema50 < ema200) and (not USE_ADX_IN_FILTER or adx is None or adx >= TREND_FILTER_ADX_MIN) + if sig == 1 and not long_ok: sig = 0 + if sig == -1 and not short_ok: sig = 0 + + # za daleko od EMA20 -> poczekaj na pullback + if sig != 0: + ema20 = _safe(df, "ema20", _ema(df["close"], 20)) + c = float(df["close"].iloc[-1]) + # przy braku atr w df liczymy atrp już powyżej + atr = atrp * c if c > 0 else 0.0 + if atr > 0.0 and abs(c - ema20) > OVEREXT_COEF * atr: + sig = 0 + + return int(sig) diff --git a/new/templates/index.html b/new/templates/index.html new file mode 100644 index 0000000..da2a5a9 --- /dev/null +++ b/new/templates/index.html @@ -0,0 +1,114 @@ + + + + + + Trading Dashboard + + + +
+

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 new file mode 100644 index 0000000..2a1d04a --- /dev/null +++ b/new/trade_log.json @@ -0,0 +1,28 @@ +[ + { + "time": "2025-08-15 12:18:01", + "action": "SELL", + "ticker": "OP-USD", + "price": 0.7549265623092651, + "qty": 3973.896468582607, + "side": -1, + "pnl_abs": 0.0, + "pnl_pct": 0.0, + "realized_pnl_cum": 0.0, + "cash_after": 10000.0, + "reason": "" + }, + { + "time": "2025-08-15 12:18:01", + "action": "SELL", + "ticker": "NEAR-USD", + "price": 2.7867467403411865, + "qty": 1076.5240904642392, + "side": -1, + "pnl_abs": 0.0, + "pnl_pct": 0.0, + "realized_pnl_cum": 0.0, + "cash_after": 10000.0, + "reason": "" + } +] \ No newline at end of file diff --git a/new/trading_bot.py b/new/trading_bot.py new file mode 100644 index 0000000..e6c52bd --- /dev/null +++ b/new/trading_bot.py @@ -0,0 +1,157 @@ +# trading_bot_workers.py +# pip install yfinance pandas numpy + +from __future__ import annotations +import time, warnings, random, multiprocessing as mp, json +from dataclasses import dataclass, asdict +from typing import Dict, List, Tuple, Optional +import pandas as pd +import yfinance as yf + +from indicators import add_indicators +from strategies import get_signal +from portfolio import Portfolio + +warnings.filterwarnings("ignore", category=FutureWarning) + +# ========= KONFIG ========= +START_CAPITAL = 10_000.0 +MIN_BARS = 200 +SCAN_EVERY_S = 60 +YF_JITTER_S = (0.05, 0.25) +PRIMARY = ("7d","1m") # preferowane: 1m/7d +FALLBACKS = [("60d","5m"), ("60d","15m")] # fallbacki gdy 1m brak + +FOREX_20 = [ + "EURUSD=X","USDJPY=X","GBPUSD=X","USDCHF=X","AUDUSD=X", + "USDCAD=X","NZDUSD=X","EURJPY=X","EURGBP=X","EURCHF=X", + "GBPJPY=X","CHFJPY=X","AUDJPY=X","AUDNZD=X","EURAUD=X", + "EURCAD=X","GBPCAD=X","CADJPY=X","NZDJPY=X","GC=F" +] +CRYPTO_20 = [ + "BTC-USD","ETH-USD","BNB-USD","SOL-USD","XRP-USD","ADA-USD","DOGE-USD","TRX-USD","TON-USD","DOT-USD", + "LTC-USD","BCH-USD","ATOM-USD","LINK-USD","XLM-USD","ETC-USD","NEAR-USD","OP-USD" +] +ALL_TICKERS = FOREX_20 + CRYPTO_20 + +# ========= Pobieranie z fallbackiem ========= +def yf_download_with_fallback(ticker: str) -> Tuple[pd.DataFrame, str, str]: + time.sleep(random.uniform(*YF_JITTER_S)) + for period, interval in (PRIMARY, *FALLBACKS): + df = yf.download(ticker, period=period, interval=interval, auto_adjust=False, progress=False, threads=False) + if df is not None and not df.empty: + df = df.rename(columns=str.lower).dropna() + if not isinstance(df.index, pd.DatetimeIndex): + df.index = pd.to_datetime(df.index) + return df, period, interval + raise ValueError("No data in primary/fallback intervals") + +# ========= Worker: jeden instrument = jeden proces ========= +@dataclass +class Signal: + ticker: str + time: str + price: float + signal: int + period: str + interval: str + error: Optional[str] = None + +def worker(ticker: str, out_q: mp.Queue, stop_evt: mp.Event, min_bars: int = MIN_BARS): + while not stop_evt.is_set(): + try: + df, used_period, used_interval = yf_download_with_fallback(ticker) + if len(df) < min_bars: + raise ValueError(f"Too few bars: {len(df)}<{min_bars} at {used_interval}") + df = add_indicators(df, min_bars=min_bars) + sig = get_signal(df) + price = float(df["close"].iloc[-1]) + ts = str(df.index[-1]) + out_q.put(Signal(ticker, ts, price, sig, used_period, used_interval)) + except Exception as e: + out_q.put(Signal(ticker, time.strftime("%Y-%m-%d %H:%M:%S"), float("nan"), 0, "NA", "NA", error=str(e))) + time.sleep(SCAN_EVERY_S) + +# ========= Pomocnicze: serializacja do JSON ========= +def _to_native(obj): + """Bezpieczny rzut na prymitywy JSON (float/int/str/bool/None).""" + if isinstance(obj, (float, int, str)) or obj is None: + return obj + if isinstance(obj, dict): + return {k: _to_native(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_to_native(x) for x in obj] + if hasattr(obj, "__dataclass_fields__"): # dataclass + return _to_native(asdict(obj)) + try: + return float(obj) + except Exception: + return str(obj) + +def save_json(path: str, data) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(_to_native(data), f, ensure_ascii=False, indent=2) + +# ========= Master (koordynacja) ========= +def main(): + mp.set_start_method("spawn", force=True) + out_q: mp.Queue = mp.Queue() + stop_evt: mp.Event = mp.Event() + + # 1 instrument = 1 proces (daemon) + procs: List[mp.Process] = [] + for t in ALL_TICKERS: + p = mp.Process(target=worker, args=(t, out_q, stop_evt), daemon=True) + p.start() + procs.append(p) + + portfolio = Portfolio(START_CAPITAL) + last_dump = 0.0 + signals_window: Dict[str, Signal] = {} + + try: + while True: + # zbieramy sygnały przez okno ~1 minuty + deadline = time.time() + SCAN_EVERY_S + while time.time() < deadline: + try: + s: Signal = out_q.get(timeout=0.5) + signals_window[s.ticker] = s + except Exception: + pass + + if signals_window: + sig_list = list(signals_window.values()) + portfolio.on_signals(sig_list) + + now = time.time() + if now - last_dump > SCAN_EVERY_S - 1: + # --- JSON zapisy --- + save_json("signals_scan.json", [asdict(s) for s in sig_list]) + save_json("portfolio_history.json", portfolio.history) + save_json("positions.json", [{"ticker": t, **p} for t, p in portfolio.positions.items()]) + + snapshot = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "last_history": portfolio.history[-1] if portfolio.history else None, + "positions": [{"ticker": t, **p} for t, p in portfolio.positions.items()], + "signals": [asdict(s) for s in sig_list], + "capital_start": START_CAPITAL + } + save_json("snapshot.json", snapshot) + + last_dump = now + + if portfolio.history: + print(pd.DataFrame(portfolio.history).tail(1).to_string(index=False)) + else: + print("Brak sygnałów w oknie – czekam...") + + except KeyboardInterrupt: + print("\nStopping workers...") + stop_evt.set() + for p in procs: + p.join(timeout=5) + +if __name__ == "__main__": + main() diff --git a/portfolio.py b/portfolio.py new file mode 100644 index 0000000..cee0213 --- /dev/null +++ b/portfolio.py @@ -0,0 +1,137 @@ +# portfolio.py +from __future__ import annotations +import math, time, json +from typing import Dict, List, Any + +def _to_native(obj: Any): + """Bezpieczne rzutowanie do typów akceptowalnych przez JSON.""" + if isinstance(obj, (float, int, str)) or obj is None: + return obj + if isinstance(obj, dict): + return {k: _to_native(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_to_native(x) for x in obj] + try: + return float(obj) + except Exception: + return str(obj) + +def save_json(path: str, data: Any) -> None: + with open(path, "w", encoding="utf-8") as f: + json.dump(_to_native(data), f, ensure_ascii=False, indent=2) + +class Portfolio: + """ + Prosta symulacja portfela: + - cash + - positions: {ticker: {"qty": float, "entry": float, "side": int}} + - equity = cash + niezrealizowany PnL + Logi transakcji z realized PnL (wartość i %), plus PnL skumulowany. + Zapis tylko w JSON. + """ + def __init__(self, capital: float): + self.cash = float(capital) + self.start_capital = float(capital) + self.positions: Dict[str, Dict] = {} + self.history: List[Dict] = [] + self.trade_log: List[Dict] = [] + self.realized_pnl: float = 0.0 # skumulowany realized PnL + + def mark_to_market(self, prices: Dict[str, float]) -> float: + unreal = 0.0 + for t, p in self.positions.items(): + price = prices.get(t) + if price is not None and not math.isnan(price): + unreal += (price - p["entry"]) * p["qty"] * p["side"] + return self.cash + unreal + + def _log_trade(self, action: str, ticker: str, price: float, qty: float, side: int, + pnl_abs: float = 0.0, pnl_pct: float = 0.0): + ts = time.strftime("%Y-%m-%d %H:%M:%S") + side_str = "LONG" if side == 1 else "SHORT" + if action == "BUY": + print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={side_str}, cash={self.cash:.2f}") + elif action == "SELL": + print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, " + f"PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), " + f"cumPnL={self.realized_pnl:+.2f}, cash={self.cash:.2f}") + # zapis do logu w pamięci + self.trade_log.append({ + "time": ts, + "action": action, + "ticker": ticker, + "price": float(price), + "qty": float(qty), + "side": int(side), + "pnl_abs": float(pnl_abs), + "pnl_pct": float(pnl_pct), + "realized_pnl_cum": float(self.realized_pnl), + "cash_after": float(self.cash) + }) + + def on_signals(self, sigs: List[dict]): + """ + sigs: lista dictów/obiektów z polami: ticker, price, signal, time + BUY (1) / SELL (-1) / HOLD (0) + """ + clean = [] + for s in sigs: + if isinstance(s, dict): + if s.get("error") is None and s.get("price") is not None and not math.isnan(s.get("price", float("nan"))): + clean.append(s) + else: + if getattr(s, "error", None) is None and not math.isnan(getattr(s, "price", float("nan"))): + clean.append({"ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time}) + + if not clean: + self.history.append({ + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "equity": self.cash, + "cash": self.cash, + "open_positions": len(self.positions) + }) + save_json("portfolio_history.json", self.history) + return + + prices = {s["ticker"]: s["price"] for s in clean} + n = len(clean) + per_trade_cash = max(self.cash / (n * 2), 0.0) + + for s in clean: + t, sig, price = s["ticker"], int(s["signal"]), float(s["price"]) + + # zamknięcie lub odwrócenie + if t in self.positions: + pos = self.positions[t] + if sig == 0 or sig != pos["side"]: + qty = pos["qty"] + entry = pos["entry"] + side = pos["side"] + self.cash += qty * price + pnl_abs = (price - entry) * qty * side + denom = max(qty * entry, 1e-12) + pnl_pct = pnl_abs / denom + self.realized_pnl += pnl_abs + self._log_trade("SELL", t, price, qty, side, pnl_abs, pnl_pct) + del self.positions[t] + + # otwarcie + if t not in self.positions and sig != 0 and per_trade_cash > 0: + qty = per_trade_cash / price + self.cash -= qty * price + self.positions[t] = {"qty": qty, "entry": price, "side": sig} + self._log_trade("BUY", t, price, qty, sig) + + equity = self.mark_to_market(prices) + self.history.append({ + "time": clean[0]["time"], + "equity": float(equity), + "cash": float(self.cash), + "open_positions": int(len(self.positions)), + "realized_pnl_cum": float(self.realized_pnl) + }) + + # zapis plików JSON + save_json("trade_log.json", self.trade_log) + save_json("portfolio_history.json", self.history) + save_json("positions.json", [{"ticker": t, **p} for t, p in self.positions.items()]) diff --git a/pythonProject.zip b/pythonProject.zip new file mode 100644 index 0000000000000000000000000000000000000000..9bf7ff511691759240e92bedb633719bfb970875 GIT binary patch literal 61134 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 + x === null || x === undefined || Number.isNaN(x) ? "–" : Number(x).toFixed(d); + +// Potencjalne adresy backendu (API). Pierwszy to aktualny origin UI. +const apiCandidates = [ + window.location.origin, + "http://127.0.0.1:8000", + "http://localhost:8000", + "http://172.27.20.120:8000", // z logów serwera +]; + +let API_BASE = null; +let warnedMixed = false; + +function withTimeout(ms = 6000) { + const ctrl = new AbortController(); + const id = setTimeout(() => ctrl.abort("timeout"), ms); + return { signal: ctrl.signal, done: () => clearTimeout(id) }; +} + +function makeUrl(path) { + return `${API_BASE}${path}${path.includes("?") ? "&" : "?"}_ts=${Date.now()}`; +} + +function setBadge(id, text, ok = true) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.className = ok ? "badge ok" : "badge err"; +} + +function setBadgeTitle(id, title) { + const el = document.getElementById(id); + if (el) el.title = title || ""; +} + +function warnMixedContent(base) { + if (!warnedMixed && location.protocol === "https:" && base?.startsWith("http://")) { + warnedMixed = true; + console.warn( + "[api] UI działa przez HTTPS, a API przez HTTP — przeglądarka może blokować żądania (mixed content)." + ); + alert( + "UI działa przez HTTPS, a API przez HTTP. Uruchom UI przez HTTP albo włącz HTTPS dla API — inaczej przeglądarka zablokuje żądania." + ); + } +} + +// ── autodetekcja backendu ───────────────────────────────────────────────────── +async function pickBackend() { + for (const base of apiCandidates) { + try { + const t = withTimeout(2500); + const r = await fetch(`${base}/api/status?_ts=${Date.now()}`, { + cache: "no-store", + signal: t.signal, + }); + t.done(); + if (r.ok) { + API_BASE = base; + console.debug("[api] using", API_BASE); + warnMixedContent(API_BASE); + setBadgeTitle("loopState", `API: ${API_BASE}`); + return; + } + console.debug("[api] probe", base, "->", r.status); + } catch (e) { + // ignorujemy i próbujemy kolejny kandydat + console.debug("[api] probe fail", base, e?.message || e); + } + } + throw new Error("Nie znaleziono działającego backendu (API_BASE). Upewnij się, że server.py działa na porcie 8000."); +} + +// ── API helpers ─────────────────────────────────────────────────────────────── +async function apiGet(path) { + const url = makeUrl(path); + const t0 = performance.now(); + const t = withTimeout(6000); + try { + const r = await fetch(url, { cache: "no-store", signal: t.signal }); + const t1 = performance.now(); + console.debug("[api] GET", url, r.status, (t1 - t0).toFixed(1) + "ms"); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + return r.json(); + } finally { + t.done(); + } +} + +async function apiPost(path, body) { + const url = makeUrl(path); + const t0 = performance.now(); + const t = withTimeout(6000); + try { + const r = await fetch(url, { + method: "POST", + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + signal: t.signal, + }); + const t1 = performance.now(); + console.debug("[api] POST", url, r.status, (t1 - t0).toFixed(1) + "ms"); + if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); + return r.json(); + } finally { + t.done(); + } +} + +// ── refreshers ──────────────────────────────────────────────────────────────── +async function refreshStatus() { + try { + const s = await apiGet("/api/status"); + setBadge("loopState", s.running ? "RUNNING" : "STOPPED", s.running); + setBadgeTitle("loopState", `API: ${API_BASE} | last_action=${s.last_action || "–"}`); + + const roundEl = document.getElementById("roundNo"); + if (roundEl) roundEl.textContent = s.round ?? "–"; + + const cashEl = document.getElementById("cash"); + if (cashEl) cashEl.textContent = fmtNum(s.cash, 2); + + // now-playing + const stageEl = document.getElementById("stage"); + if (stageEl) stageEl.textContent = (s.stage || "idle").toUpperCase(); + + const tickerEl = document.getElementById("ticker"); + if (tickerEl) tickerEl.textContent = s.current_ticker || "–"; + + const idx = s.current_index ?? 0; + const total = s.tickers_total ?? 0; + + const progressTextEl = document.getElementById("progressText"); + if (progressTextEl) progressTextEl.textContent = `${Math.min(idx + 1, total)} / ${total}`; + + const prog = document.getElementById("progress"); + if (prog) { + prog.max = total || 1; + prog.value = Math.min(idx + 1, total) || 0; + } + + const lastActionEl = document.getElementById("lastAction"); + if (lastActionEl) lastActionEl.textContent = s.last_action || "–"; + } catch (e) { + console.error("status error:", e); + setBadge("loopState", "ERR", false); + setBadgeTitle("loopState", `API: ${API_BASE || "—"} | ${e?.message || e}`); + } +} + +async function refreshPositions() { + try { + const { positions } = await apiGet("/api/positions"); + const tbody = document.querySelector("#positions tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + for (const p of positions) { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${p.ticker} + ${p.side} + ${fmtNum(p.size, 0)} + ${fmtNum(p.entry_price)} + ${fmtNum(p.last_price)} + ${fmtNum(p.pnl)} + `; + tbody.appendChild(tr); + } + } catch (e) { + console.error("positions error:", e); + } +} + +async function refreshTrades() { + try { + const { trades } = await apiGet("/api/trades"); + const tbody = document.querySelector("#trades tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + trades.slice(-50).reverse().forEach((t) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${t.time ?? ""} + ${t.ticker ?? ""} + ${t.action ?? ""} + ${fmtNum(t.price)} + ${fmtNum(t.size, 0)} + `; + tbody.appendChild(tr); + }); + } catch (e) { + console.error("trades error:", e); + } +} + +async function refreshAll() { + await Promise.all([refreshStatus(), refreshPositions(), refreshTrades()]); +} + +// ── auto refresh ────────────────────────────────────────────────────────────── +let timer = null; +let currentInterval = 2000; // domyślnie 2s (zgodne z Twoim selectem) + +function startAutoRefresh() { + if (timer) return; + timer = setInterval(refreshAll, currentInterval); + console.debug("[ui] auto refresh started", currentInterval, "ms"); +} +function stopAutoRefresh() { + if (!timer) return; + clearInterval(timer); + timer = null; + console.debug("[ui] auto refresh stopped"); +} + +// ── bootstrap ──────────────────────────────────────────────────────────────── +document.addEventListener("DOMContentLoaded", async () => { + // Przyciski + document.getElementById("btnStart")?.addEventListener("click", async () => { + try { await apiPost("/api/start"); await refreshStatus(); } catch (e) { console.error(e); } + }); + document.getElementById("btnStop")?.addEventListener("click", async () => { + try { await apiPost("/api/stop"); await refreshStatus(); } catch (e) { console.error(e); } + }); + document.getElementById("btnTick")?.addEventListener("click", async () => { + try { await apiPost("/api/run-once"); await refreshAll(); } catch (e) { console.error(e); } + }); + + const sel = document.getElementById("refreshMs"); + sel?.addEventListener("change", () => { + currentInterval = parseInt(sel.value, 10); + if (timer) { stopAutoRefresh(); startAutoRefresh(); } + }); + + document.getElementById("autoOn")?.addEventListener("click", startAutoRefresh); + document.getElementById("autoOff")?.addEventListener("click", stopAutoRefresh); + + // Autodetekcja backendu przed pierwszym odświeżeniem + try { + await pickBackend(); + await refreshAll(); + startAutoRefresh(); + } catch (e) { + console.error(e); + setBadge("loopState", "NO API", false); + setBadgeTitle("loopState", e?.message || String(e)); + alert("UI nie może połączyć się z backendem (port 8000). Uruchom server.py lub zaktualizuj API_BASE w app.js."); + } +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..17f4d82 --- /dev/null +++ b/static/style.css @@ -0,0 +1,41 @@ +:root{ + --border:#e5e7eb; + --muted:#64748b; +} +*{ box-sizing:border-box; } +body{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin:0; color:#0f172a; background:#fff; } +.container{ max-width: 1100px; margin: 0 auto; padding: 16px; } +header{ display:flex; align-items:center; justify-content:space-between; margin-bottom: 16px; gap: 12px; } +h1{ margin:0; font-size: 22px; } +h2{ font-size:18px; margin: 18px 0 10px; } +h3{ font-size:16px; margin:0; } + +#statusBar{ display:flex; align-items:center; gap: 12px; flex-wrap: wrap; } +#statusBar span{ font-size:14px; color:#0f172a; } +.btn{ + appearance:none; border:1px solid var(--border); background:#0f172a; color:#fff; + padding:8px 12px; border-radius:10px; cursor:pointer; font-weight:600; +} +.btn:hover{ opacity:.9; } +.btn-secondary{ background:#475569; } + +.grid{ display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 24px; } +.card{ background:#0f172a0d; border:1px solid var(--border); border-radius:12px; padding:12px; } +.kpi{ font-size: 24px; font-weight: 700; margin-top: 8px; } +.table-wrap{ overflow:auto; border:1px solid var(--border); border-radius:12px; } +table{ width:100%; border-collapse: collapse; font-size: 14px; } +th, td{ padding: 8px 10px; border-bottom: 1px solid #f1f5f9; white-space: nowrap; } +thead th{ position: sticky; top: 0; background: #fff; z-index: 1; } +tbody tr:hover{ background:#f8fafc; } + +.pnl-pos{ color: #166534; font-weight:600; } +.pnl-neg{ color: #991b1b; font-weight:600; } + +.badge{ + padding:3px 8px; border-radius:999px; font-size:12px; border:1px solid var(--border); + display:inline-block; +} +.badge.long{ background:#ecfdf5; border-color:#bbf7d0; } +.badge.short{ background:#fef2f2; border-color:#fecaca; } + +.empty{ text-align:center; color: var(--muted); padding: 14px; } diff --git a/strategy.py b/strategy.py new file mode 100644 index 0000000..bbb3c2b --- /dev/null +++ b/strategy.py @@ -0,0 +1,93 @@ +from __future__ import annotations +from dataclasses import dataclass +import math +import numpy as np +import pandas as pd +from config import CFG +# zakładam, że masz te funkcje gdzieś u siebie: +# from indicators import ema, rsi, atr, macd, adx_val +from indicators import ema, rsi, atr, macd, adx_val + +Signal = str # "BUY" | "SELL" | "NONE" + +@dataclass +class Decision: + signal: Signal + sl: float | None + tp: float | None + rpu: float # risk per unit (odległość od SL) + +def evaluate_signal(df: pd.DataFrame) -> Decision: + """ + Warunki (poluzowane / opcjonalne): + - (opcjonalnie) kierunek EMA200 (trend filter), + - ADX >= CFG.adx_min, + - ATR >= CFG.atr_min_frac_price * price, + - RSI nieprzeciążone (BUY < rsi_buy_max, SELL > rsi_sell_min), + - (opcjonalnie) MACD zgodny z kierunkiem. + Wyjście: SL = k_ATR, TP = RR * R (R = dystans do SL). + """ + if df is None or len(df) < CFG.history_min_bars: + return Decision("NONE", None, None, 0.0) + + # Bezpieczne pobranie kolumn + cols = {c.lower(): c for c in df.columns} + C = pd.to_numeric(df[cols.get("close","Close")], errors="coerce").to_numpy(float) + H = pd.to_numeric(df[cols.get("high","High")], errors="coerce").to_numpy(float) + L = pd.to_numeric(df[cols.get("low","Low")], errors="coerce").to_numpy(float) + + if len(C) == 0 or np.isnan(C[-1]): + return Decision("NONE", None, None, 0.0) + + # wskaźniki + ema200 = ema(C, 200) + rsi14 = rsi(C, CFG.rsi_len) + atr14 = atr(H, L, C, 14) + macd_line, macd_sig, macd_hist = macd(C, 12, 26, 9) + adx14, pdi, mdi = adx_val(H, L, C, 14) + + c = float(C[-1]) + a = float(atr14[-1]) if math.isfinite(atr14[-1]) else 0.0 + + if not (math.isfinite(c) and math.isfinite(a)) or a <= 0: + return Decision("NONE", None, None, 0.0) + + # filtry bazowe + adx_ok = float(adx14[-1]) >= CFG.adx_min + atr_ok = (a / max(1e-9, c)) >= CFG.atr_min_frac_price + + # trend (opcjonalny) + trend_ok_buy = True if not CFG.require_trend else (c > float(ema200[-1])) + trend_ok_sell = True if not CFG.require_trend else (c < float(ema200[-1])) + + # RSI „nieprzeciążone” + rsi_val = float(rsi14[-1]) + rsi_ok_buy = rsi_val <= CFG.rsi_buy_max + rsi_ok_sell = rsi_val >= CFG.rsi_sell_min + + # MACD (opcjonalny) + if CFG.use_macd_filter: + macd_buy = (macd_line[-1] > macd_sig[-1] and macd_hist[-1] > 0) + macd_sell = (macd_line[-1] < macd_sig[-1] and macd_hist[-1] < 0) + else: + macd_buy = macd_sell = True + + signal: Signal = "NONE" + sl = tp = None + rpu = 0.0 + + if adx_ok and atr_ok: + if trend_ok_buy and rsi_ok_buy and macd_buy: + signal = "BUY" + sl = c - CFG.sl_atr_mult * a + risk = c - sl + tp = c + CFG.tp_rr * risk + rpu = max(1e-9, risk) + elif CFG.allow_short and trend_ok_sell and rsi_ok_sell and macd_sell: + signal = "SELL" + sl = c + CFG.sl_atr_mult * a + risk = sl - c + tp = c - CFG.tp_rr * risk + rpu = max(1e-9, risk) + + return Decision(signal, sl, tp, rpu) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..c55391a --- /dev/null +++ b/templates/index.html @@ -0,0 +1,81 @@ + + + + + + Trader – Panel + + + + +
+ Loop: + + + + + + | Auto-refresh: + + + + + | Runda: + | Gotówka: +
+ +
+ Teraz: + + + 0 / 0 + + Ostatnia akcja: + +
+ +
+
+

Pozycje

+ + + + + +
TickerStronaIlośćWejścieOstatniaPnL
+
+
+

Transakcje (ostatnie 50)

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