This commit is contained in:
Patryk Zamorski 2025-08-15 12:32:27 +02:00
parent 861faf4ee4
commit 7e722ca77f
34 changed files with 1347 additions and 2995 deletions

226
README.md
View File

@ -1,226 +0,0 @@
# Multi-Asset Portfolio Simulator (Yahoo Finance, Python)
Szybki symulator wielo-aktywny z jedną wspólną kasą.
Działa w pętli co 2 minuty i dla paczki tickerów:
- pobiera **batch** danych z Yahoo jednym zapytaniem (`yfinance.download`),
- **dopina tylko nowe świece** (cache ostatniego czasu per ticker),
- liczy sygnał na **close** każdej świecy,
- **egzekwuje** zlecenie na **kolejnym open** (bardziej realistycznie),
- prowadzi portfel (wspólne saldo, SL/TP intrabar, prowizja, poślizg),
- zapisuje wyniki do `dane/` oraz liczy metryki **MaxDD, Sharpe, CAGR**.
> Uwaga: to **symulacja** oparta o dane z Yahoo. Nie składa prawdziwych zleceń.
---
## Funkcje (skrót)
- Multi-asset (~20 domyślnych: krypto + forex + złoto `GC=F`)
- Batch download (znacznie szybciej niż pojedynczo)
- Tylko nowe świece (bez pobierania pełnej historii co rundę)
- Sygnał: EMA200 + SMA(20/50) + RSI(14) + ATR-SL/TP + filtry:
**MACD, Stochastic, Bollinger, ADX, Supertrend**
- Egzekucja na **kolejnym OPEN**
- Portfel: **jedna** kasa dla wszystkich instrumentów, risk per trade (% equity)
- Zapisy CSV + **portfolio_summary** z MaxDD / Sharpe (annual) / CAGR
---
## Wymagania
- Python 3.9+
- `pip install yfinance pandas numpy`
---
## Szybki start
```bash
# 1) instalacja
chmod +x install.sh
./install.sh
# 2) uruchomienie (wirtualne środowisko)
source venv/bin/activate
python app.py
# (opcjonalnie w tle)
nohup python app.py > output.log 2>&1 &
```
Logi lecą do `stdout` (lub do `output.log` przy `nohup`).
---
## Struktura plików
```
project/
├─ app.py # główna pętla: batch → nowe świece → decyzje → egzekucja → zapis
├─ config.py # konfiguracja (tickery, interwał, risk, katalog 'dane', itp.)
├─ data.py # fetch_batch(): pobieranie paczki tickerów z yfinance
├─ indicators.py # implementacje wskaźników (SMA/EMA/RSI/ATR/MACD/…)
├─ strategy.py # evaluate_signal(): logika generowania sygnałów + SL/TP + rpu
├─ portfolio.py # model portfela: pozycje, pending orders, SL/TP, PnL, equity
├─ metrics.py # metryki portfelowe: MaxDD, Sharpe (annualized), CAGR
├─ io_utils.py # zapisy do 'dane/': trades, equity, summary; tworzenie katalogów
├─ requirements.txt # zależności (yfinance, pandas, numpy)
└─ install.sh # instalator: venv + pip install -r requirements.txt
```
### Co jest w każdym pliku?
- **`config.py`**
Definiuje `CFG` (tickers, interwał, okres pobierania `yf_period`, minimalna historia, risk, kasa startowa, SL/TP, katalog wyjściowy).
Zmienisz tu listę instrumentów lub parametry ryzyka.
- **`data.py`**
`fetch_batch(tickers, period, interval)` — jedno zapytanie do Yahoo dla wielu tickerów. Zwraca słownik `{ticker: DataFrame}` z kolumnami `open, high, low, close, volume`.
- **`indicators.py`**
Wskaźniki: `sma, ema, rsi, atr, macd, stoch_kd, bollinger, adx_val, supertrend`.
- **`strategy.py`**
`evaluate_signal(df)``Decision(signal, sl, tp, rpu)`
- **signal**: `BUY` / `SELL` / `NONE`
- **sl/tp**: poziomy na bazie ATR i RR
- **rpu** (*risk per unit*): ile ryzyka na jednostkę (do wyliczenia wielkości pozycji)
- **`portfolio.py`**
Model portfela z jedną kasą:
- egzekucja **na kolejnym OPEN** (pending orders),
- SL/TP intrabar,
- prowizja i poślizg,
- `portfolio_equity` (czas, equity) i lista `trades`.
- **`metrics.py`**
Metryki portfela na bazie krzywej equity:
- **Max Drawdown** minimalna wartość `equity/rolling_max - 1`,
- **Sharpe annualized** z 1-min zwrotów (525 600 okresów/rok),
- **CAGR** roczna złożona stopa wzrostu.
- **`io_utils.py`**
Tworzy strukturę `dane/`, zapisuje:
- `dane/portfolio_equity.csv` equity w czasie,
- `dane/portfolio_summary.txt` JSON z metrykami i statystyką,
- `dane/<TICKER>/<TICKER>_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.

138
app.py
View File

@ -1,76 +1,70 @@
from __future__ import annotations
import time
from typing import Dict
import pandas as pd
from config import CFG
from data import fetch_batch
from strategy import evaluate_signal
from portfolio import Portfolio
from io_utils import ensure_dirs, save_outputs
import os, json, threading
from flask import Flask, jsonify, render_template, abort
from trading_bot import main as trading_bot_main # dostosuj jeśli moduł nazywa się inaczej
def interval_to_timedelta(interval: str) -> pd.Timedelta:
# prosta mapka dla 1m/2m/5m/15m/30m/60m/1h
mapping = {
"1m":"1min","2m":"2min","5m":"5min","15m":"15min","30m":"30min",
"60m":"60min","90m":"90min","1h":"60min"
}
key = mapping.get(interval, "1min")
return pd.to_timedelta(key)
# Katalog projektu (tam gdzie leży ten plik)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# Domyślnie dane są w głównym katalogu projektu; można nadpisać przez env DATA_DIR
DATA_DIR = os.environ.get("DATA_DIR", BASE_DIR)
def _load_json(name: str):
path = os.path.join(DATA_DIR, name)
if not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return None
app = Flask(
__name__,
static_folder="static",
static_url_path="/static", # <-- ważne: z wiodącym slashem
template_folder="templates",
)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/snapshot")
def api_snapshot():
data = _load_json("snapshot.json")
if data is None:
abort(404, description="snapshot.json not found")
return jsonify(data)
@app.route("/api/history")
def api_history():
data = _load_json("portfolio_history.json")
if data is None:
abort(404, description="portfolio_history.json not found")
return jsonify(data)
@app.route("/api/positions")
def api_positions():
data = _load_json("positions.json")
if data is None:
data = []
return jsonify(data)
@app.route("/api/trades")
def api_trades():
data = _load_json("trade_log.json")
if data is None:
data = []
return jsonify(data)
def start_trading_bot():
"""Uruchamia bota w osobnym wątku."""
trading_thread = threading.Thread(target=trading_bot_main, daemon=True)
trading_thread.start()
print("[Flask] Trading bot uruchomiony w wątku.")
if __name__ == "__main__":
ensure_dirs(CFG.root_dir, CFG.tickers)
portfolio = Portfolio(starting_cash=CFG.starting_cash, commission_per_trade=1.0, slippage_bp=1.0)
last_ts: Dict[str, pd.Timestamp] = {}
hist: Dict[str, pd.DataFrame] = {}
bar_delta = interval_to_timedelta(CFG.interval)
while True:
round_t0 = time.time()
batch = fetch_batch(CFG.tickers, CFG.yf_period, CFG.interval)
for tk, df in batch.items():
if df.empty:
continue
df = df.copy()
df.index = pd.to_datetime(df.index, utc=True)
prev = hist.get(tk)
if prev is not None and not prev.empty:
df_all = pd.concat([prev, df[~df.index.isin(prev.index)]], axis=0).sort_index()
else:
df_all = df.sort_index()
hist[tk] = df_all.tail(2000)
last = last_ts.get(tk)
new_part = df_all[df_all.index > last] if last is not None else df_all
if not new_part.empty:
for ts, row in new_part.iterrows():
o,h,l,c = float(row["open"]), float(row["high"]), float(row["low"]), float(row["close"])
# 1) egzekucja oczekujących na OPEN tego baraz
portfolio.on_new_bar(tk, ts, o,h,l,c)
# 2) sygnał na CLOSE → plan na KOLEJNY OPEN
df_upto = hist[tk].loc[:ts]
dec = evaluate_signal(df_upto)
portfolio.schedule_order(
tk, dec.signal, dec.rpu, dec.sl, dec.tp,
next_bar_ts=ts + pd.to_timedelta(1, unit="min"),
ref_price=float(df_upto["close"].iloc[-1])
)
last_ts[tk] = new_part.index[-1]
# zapis
import pandas as pd
trades_df = pd.DataFrame(portfolio.trades)
eq_df = pd.DataFrame(portfolio.portfolio_equity, columns=["time","equity"])
save_outputs(CFG.root_dir, CFG.tickers, trades_df, eq_df, portfolio.cash)
elapsed = time.time() - round_t0
sleep_s = max(0, 120 - elapsed)
print(f"Runda OK ({elapsed:.1f}s). Pauza {sleep_s:.1f}s.")
print(sleep_s)
time.sleep(sleep_s)
print(f"[Flask] DATA_DIR: {DATA_DIR}")
start_trading_bot()
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)

View File

@ -1,57 +0,0 @@
from dataclasses import dataclass, field
from typing import List
@dataclass
class Settings:
# 20 FX
fx: List[str] = field(default_factory=lambda: [
"EURUSD=X","GBPUSD=X","USDJPY=X","USDCHF=X","USDCAD=X",
"AUDUSD=X","NZDUSD=X","EURGBP=X","EURJPY=X","EURCHF=X",
"GBPJPY=X","AUDJPY=X","CHFJPY=X","CADJPY=X","EURAUD=X",
"EURNZD=X","GBPAUD=X","GBPCAD=X","AUDCAD=X","NZDJPY=X",
])
# 20 CRYPTO
crypto: List[str] = field(default_factory=lambda: [
"BTC-USD","ETH-USD","BNB-USD","SOL-USD","XRP-USD",
"ADA-USD","DOGE-USD","TRX-USD","DOT-USD","AVAX-USD",
"MATIC-USD","LTC-USD","BCH-USD","LINK-USD","ATOM-USD",
"XMR-USD","XLM-USD","ETC-USD","FIL-USD","NEAR-USD",
])
yf_period: str = "5d"
interval: str = "1m"
starting_cash: float = 10000.0
loop_sleep_s: int = 2
root_dir: str = "out"
commission_per_trade: float = 1.0
slippage_bp: float = 1.0
# — parametry strategii —
history_min_bars: int = 220
rsi_len: int = 14
# filtry poluzowane
require_trend: bool = False # ignoruj EMA200 gdy False
use_macd_filter: bool = False # ignoruj MACD gdy False
adx_min: float = 8.0
atr_min_frac_price: float = 1e-4
rsi_buy_max: float = 75.0
rsi_sell_min: float = 25.0
# SL/TP
sl_atr_mult: float = 1.3
tp_rr: float = 1.5
# sizing na ryzyko
risk_per_trade_frac: float = 0.02 # 2% equity na 1R
min_size: float = 1.0
max_size: float = 100000.0
# shorty
allow_short: bool = True
@property
def tickers(self) -> List[str]:
return self.fx + self.crypto
CFG = Settings()

84
data.py
View File

@ -1,84 +0,0 @@
from __future__ import annotations
from typing import Dict, List
import time, logging
import pandas as pd
import yfinance as yf
log = logging.getLogger("data")
def _normalize_ohlc(df: pd.DataFrame) -> pd.DataFrame:
"""Ujednolica kolumny do: Open, High, Low, Close, Volume. Obsługa lowercase, MultiIndex, Adj Close."""
if df is None or len(df) == 0:
return pd.DataFrame(columns=["Open","High","Low","Close","Volume"])
df = df.copy()
# Flatten MultiIndex -> zwykłe nazwy
if isinstance(df.columns, pd.MultiIndex):
df.columns = [str(tuple(filter(None, map(str, c)))).strip("()").replace("'", "").replace(" ", "") if isinstance(c, tuple) else str(c) for c in df.columns]
# po flatten często nazwy są np. 'Close,EURUSD=X' weź pierwszy człon przed przecinkiem
df.columns = [c.split(",")[0] for c in df.columns]
# Zrzuć TZ
if isinstance(df.index, pd.DatetimeIndex) and df.index.tz is not None:
df.index = df.index.tz_localize(None)
# Ujednolicenie do TitleCase
norm = {c: str(c).strip() for c in df.columns}
# mapuj najczęstsze warianty
mapping = {}
for c in norm.values():
lc = c.lower()
if lc in ("open", "op", "o"): mapping[c] = "Open"
elif lc in ("high", "hi", "h"): mapping[c] = "High"
elif lc in ("low", "lo", "l"): mapping[c] = "Low"
elif lc in ("close", "cl", "c"): mapping[c] = "Close"
elif lc in ("adj close","adjclose","adjustedclose"): mapping[c] = "Adj Close"
elif lc in ("volume","vol","v"): mapping[c] = "Volume"
else:
# zostaw jak jest (np. 'Dividends', 'Stock Splits')
mapping[c] = c
df.rename(columns=mapping, inplace=True)
# Jeśli brak Close, ale jest Adj Close -> użyj go
if "Close" not in df.columns and "Adj Close" in df.columns:
df["Close"] = df["Adj Close"]
# Upewnij się, że są wszystkie podstawowe kolumny (dodaj puste jeśli brak)
for need in ["Open","High","Low","Close","Volume"]:
if need not in df.columns:
df[need] = pd.NA
# Pozostaw tylko rdzeń (kolejność stała)
df = df[["Open","High","Low","Close","Volume"]]
return df
def _fetch_single(ticker: str, period: str, interval: str, tries: int = 3, sleep_s: float = 0.7) -> pd.DataFrame:
"""Pobierz OHLC dla 1 tickera (z retry) i znormalizuj kolumny."""
for i in range(1, tries + 1):
try:
log.info("Yahoo: get %s (try %d/%d) period=%s interval=%s", ticker, i, tries, period, interval)
df = yf.Ticker(ticker).history(period=period, interval=interval, auto_adjust=False, prepost=False)
df = _normalize_ohlc(df)
if len(df) == 0 or "Close" not in df.columns:
log.warning("Yahoo: %s -> EMPTY or no Close (cols=%s)", ticker, list(df.columns))
return df
except Exception as e:
log.warning("Yahoo: error %s (try %d/%d): %s", ticker, i, tries, e)
time.sleep(sleep_s)
# po niepowodzeniu zwróć pusty rdzeń
return pd.DataFrame(columns=["Open","High","Low","Close","Volume"])
def fetch_batch(tickers: List[str], period: str, interval: str) -> Dict[str, pd.DataFrame]:
"""Pobierz paczkę danych dla listy tickerów."""
out: Dict[str, pd.DataFrame] = {}
for tk in tickers:
df = _fetch_single(tk, period, interval)
out[tk] = df
if len(df):
first = str(df.index[0]); last = str(df.index[-1])
else:
first = last = "-"
log.info("Yahoo: %s -> bars=%d %s..%s cols=%s", tk, len(df), first, last, list(df.columns))
return out

View File

@ -1,33 +1,50 @@
# indicators.py
from __future__ import annotations
import math
from typing import Tuple
import numpy as np
import pandas as pd
# ===== Helper =====
def _as_1d(a) -> np.ndarray:
return np.asarray(a, dtype=float).ravel()
# ===== Indicators =====
def sma(arr: np.ndarray, period: int) -> np.ndarray:
if len(arr) < period: return np.full(len(arr), np.nan)
arr = _as_1d(arr); n = len(arr)
if n == 0: return np.array([], float)
if n < period: return np.full(n, np.nan)
w = np.ones(period) / period
out = np.convolve(arr, w, mode="valid")
return np.concatenate([np.full(period-1, np.nan), out])
def ema(arr: np.ndarray, period: int) -> np.ndarray:
out = np.full(len(arr), np.nan, dtype=float); k = 2/(period+1); e = np.nan
for i,x in enumerate(arr):
e = x if math.isnan(e) else x*k + e*(1-k)
arr = _as_1d(arr); n = len(arr)
out = np.full(n, np.nan, float)
if n == 0: return out
k = 2/(period+1); e = np.nan
for i, x in enumerate(arr):
e = x if (isinstance(e, float) and math.isnan(e)) else x*k + e*(1-k)
out[i] = e
return out
def rsi(arr: np.ndarray, period: int=14) -> np.ndarray:
if len(arr) < period+1: return np.full(len(arr), np.nan)
d = np.diff(arr, prepend=arr[0]); g = np.where(d>0,d,0.0); L = np.where(d<0,-d,0.0)
ag, al = ema(g,period), ema(L,period); rs = ag/(al+1e-9)
def rsi(arr: np.ndarray, period: int = 14) -> np.ndarray:
arr = _as_1d(arr); n = len(arr)
if n == 0: return np.array([], float)
if n < period+1: return np.full(n, np.nan)
d = np.diff(arr, prepend=arr[0])
g = np.where(d > 0, d, 0.0)
L = np.where(d < 0, -d, 0.0)
ag, al = ema(g, period), ema(L, period)
rs = ag / (al + 1e-9)
return 100 - (100 / (1 + rs))
def atr(H: np.ndarray, L: np.ndarray, C: np.ndarray, period: int=14) -> np.ndarray:
if len(C) < period+1: return np.full(len(C), np.nan)
pc = np.roll(C,1)
tr = np.maximum.reduce([H-L, np.abs(H-pc), np.abs(L-pc)])
tr[0] = H[0]-L[0]
def atr(H: np.ndarray, L: np.ndarray, C: np.ndarray, period: int = 14) -> np.ndarray:
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0: return np.array([], float)
if n < period+1: return np.full(n, np.nan)
pc = np.roll(C, 1)
tr = np.maximum.reduce([H - L, np.abs(H - pc), np.abs(L - pc)])
tr[0] = H[0] - L[0]
return ema(tr, period)
def macd(arr: np.ndarray, fast=12, slow=26, signal=9):
@ -38,7 +55,11 @@ def macd(arr: np.ndarray, fast=12, slow=26, signal=9):
return line, sig, hist
def stoch_kd(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14, smooth=3):
if len(C) < period: return np.full(len(C), np.nan), np.full(len(C), np.nan)
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0:
z = np.array([], float); return z, z
if n < period:
return np.full(n, np.nan), np.full(n, np.nan)
lowest = pd.Series(L).rolling(period, min_periods=1).min().to_numpy()
highest = pd.Series(H).rolling(period, min_periods=1).max().to_numpy()
k = 100 * (C - lowest) / (highest - lowest + 1e-9)
@ -46,31 +67,59 @@ def stoch_kd(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14, smooth=3):
return k, d
def bollinger(arr: np.ndarray, period=20, dev=2.0):
arr = _as_1d(arr); n = len(arr)
if n == 0:
z = np.array([], float); return z, z, z
s = pd.Series(arr)
ma = s.rollizng(period, min_periods=1).mean().to_numpy()
ma = s.rolling(period, min_periods=1).mean().to_numpy()
sd = s.rolling(period, min_periods=1).std(ddof=0).to_numpy()
return ma, ma + dev*sd, ma - dev*sd
def adx_val(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14):
up_move = H - np.roll(H,1); down_move = np.roll(L,1) - L
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0:
z = np.array([], float); return z, z, z
up_move = H - np.roll(H, 1); down_move = np.roll(L, 1) - L
up_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0)
down_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0)
tr1 = H - L; tr2 = np.abs(H - np.roll(C,1)); tr3 = np.abs(L - np.roll(C,1))
tr1 = H - L; tr2 = np.abs(H - np.roll(C, 1)); tr3 = np.abs(L - np.roll(C, 1))
tr = np.maximum.reduce([tr1, tr2, tr3]); tr[0] = tr1[0]
atr14 = ema(tr, period)
pdi = 100 * ema(up_dm, period)/(atr14+1e-9)
mdi = 100 * ema(down_dm, period)/(atr14+1e-9)
dx = 100 * np.abs(pdi - mdi)/(pdi + mdi + 1e-9)
pdi = 100 * ema(up_dm, period) / (atr14 + 1e-9)
mdi = 100 * ema(down_dm, period) / (atr14 + 1e-9)
dx = 100 * np.abs(pdi - mdi) / (pdi + mdi + 1e-9)
adx = ema(dx, period)
return adx, pdi, mdi
def supertrend(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=10, mult=3.0):
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0: return np.array([], float)
a = atr(H, L, C, period).copy()
hl2 = (H + L)/2.0
ub = hl2 + mult*a
lb = hl2 - mult*a
n = len(C); st = np.full(n, np.nan)
hl2 = (H + L) / 2.0
ub = hl2 + mult * a
lb = hl2 - mult * a
st = np.full(n, np.nan)
for i in range(1, n):
prev = st[i-1] if not np.isnan(st[i-1]) else hl2[i-1]
st[i] = ub[i] if C[i-1] <= prev else lb[i]
return st
# ===== Pipeline: add_indicators =====
def add_indicators(df: pd.DataFrame, min_bars: int = 200) -> pd.DataFrame:
if len(df) < min_bars:
raise ValueError(f"Too few bars: {len(df)} < {min_bars}")
C, H, L = df["close"].to_numpy(), df["high"].to_numpy(), df["low"].to_numpy()
df["ema20"] = ema(C, 20)
df["ema50"] = ema(C, 50)
df["rsi14"] = rsi(C, 14)
df["atr14"] = atr(H, L, C, 14)
m_line, m_sig, _ = macd(C)
df["macd"], df["macd_sig"] = m_line, m_sig
k, d = stoch_kd(H, L, C, 14, 3)
df["stoch_k"], df["stoch_d"] = k, d
mid, up, dn = bollinger(C, 20, 2.0)
df["bb_mid"], df["bb_up"], df["bb_dn"] = mid, up, dn
adx_, pdi, mdi = adx_val(H, L, C, 14)
df["adx"], df["pdi"], df["mdi"] = adx_, pdi, mdi
df["supertrend"] = supertrend(H, L, C, 10, 3.0)
return df

View File

@ -1,14 +0,0 @@
#!/usr/bin/env bash
set -e
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
echo "OK. Start:"
echo "source venv/bin/activate && python app.py"
#chmod +x install.sh
#./install.sh
#source venv/bin/activate
#nohup python app.py > output.log 2>&1 &

View File

@ -1,37 +0,0 @@
from __future__ import annotations
import os, json
import pandas as pd
from typing import List
from metrics import portfolio_metrics
def ensure_dirs(root_dir: str, tickers: List[str]):
os.makedirs(root_dir, exist_ok=True)
for tk in tickers:
os.makedirs(os.path.join(root_dir, tk), exist_ok=True)
def save_outputs(root_dir: str, tickers: List[str], trades: pd.DataFrame, portfolio_eq: pd.DataFrame, cash: float):
ensure_dirs(root_dir, tickers)
if not trades.empty:
for tk, tdf in trades.groupby("ticker"):
tdf2 = tdf.copy()
tdf2["datetime"] = pd.to_datetime(tdf2["time"], unit="ms", utc=True)
tdf2.to_csv(os.path.join(root_dir, tk, f"{tk}_trades.csv"), index=False)
if not portfolio_eq.empty:
portfolio_eq["datetime"] = pd.to_datetime(portfolio_eq["time"], unit="ms", utc=True)
portfolio_eq.to_csv(os.path.join(root_dir, "portfolio_equity.csv"), index=False)
eq_series = portfolio_eq.set_index("time")["equity"] if not portfolio_eq.empty else pd.Series(dtype=float)
metrics = portfolio_metrics(eq_series) if not eq_series.empty else {"max_dd":0.0,"sharpe":0.0,"cagr":0.0}
summary = {
"ending_cash": round(cash, 2),
"n_open_positions": int(trades[trades["action"]=="OPEN"]["ticker"].nunique()) if not trades.empty else 0,
"n_trades_closed": int((trades["action"]=="CLOSE").sum()) if not trades.empty else 0,
"max_drawdown": metrics["max_dd"],
"sharpe_annualized": metrics["sharpe"],
"cagr": metrics["cagr"],
}
with open(os.path.join(root_dir, "portfolio_summary.txt"), "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)

View File

@ -1,26 +0,0 @@
from __future__ import annotations
import math
import pandas as pd
from typing import Dict
def portfolio_metrics(equity: pd.Series) -> Dict[str, float]:
if equity.empty or equity.iloc[0] <= 0:
return {"max_dd": 0.0, "sharpe": 0.0, "cagr": 0.0}
roll_max = equity.cummax()
dd = (equity/roll_max) - 1.0
max_dd = float(dd.min())
rets = equity.pct_change().dropna()
if rets.empty or rets.std(ddof=0) == 0:
sharpe = 0.0
else:
periods_per_year = 365*24*60 # dla 1m
sharpe = float((rets.mean()/rets.std(ddof=0)) * math.sqrt(periods_per_year))
t0 = pd.to_datetime(equity.index[0], unit="ms", utc=True)
t1 = pd.to_datetime(equity.index[-1], unit="ms", utc=True)
years = max((t1 - t0).total_seconds() / (365.25*24*3600), 1e-9)
cagr = float((equity.iloc[-1]/equity.iloc[0])**(1/years) - 1.0)
return {"max_dd": max_dd, "sharpe": sharpe, "cagr": cagr}

View File

@ -1,61 +0,0 @@
from __future__ import annotations
import os, json, threading
from flask import Flask, jsonify, render_template, abort
from trading_bot import main as trading_bot_main # zakładam, że tak nazywasz plik
DATA_DIR = os.environ.get("DATA_DIR", ".")
def _load_json(name: str):
path = os.path.join(DATA_DIR, name)
if not os.path.isfile(path):
return None
with open(path, "r", encoding="utf-8") as f:
try:
return json.load(f)
except Exception:
return None
app = Flask(
__name__,
static_folder="static",
static_url_path="/static",
template_folder="templates"
)
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/snapshot")
def api_snapshot():
data = _load_json("snapshot.json")
if data is None: abort(404, "snapshot.json not found")
return jsonify(data)
@app.route("/api/history")
def api_history():
data = _load_json("portfolio_history.json")
if data is None: abort(404, "portfolio_history.json not found")
return jsonify(data)
@app.route("/api/positions")
def api_positions():
data = _load_json("positions.json")
if data is None: data = []
return jsonify(data)
@app.route("/api/trades")
def api_trades():
data = _load_json("trade_log.json")
if data is None: data = []
return jsonify(data)
def start_trading_bot():
"""Uruchamia bota w osobnym wątku."""
trading_thread = threading.Thread(target=trading_bot_main, daemon=True)
trading_thread.start()
print("[Flask] Trading bot uruchomiony w wątku.")
if __name__ == "__main__":
start_trading_bot()
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)

View File

@ -1,125 +0,0 @@
# indicators.py
from __future__ import annotations
import math
import numpy as np
import pandas as pd
# ===== Helper =====
def _as_1d(a) -> np.ndarray:
return np.asarray(a, dtype=float).ravel()
# ===== Indicators =====
def sma(arr: np.ndarray, period: int) -> np.ndarray:
arr = _as_1d(arr); n = len(arr)
if n == 0: return np.array([], float)
if n < period: return np.full(n, np.nan)
w = np.ones(period) / period
out = np.convolve(arr, w, mode="valid")
return np.concatenate([np.full(period-1, np.nan), out])
def ema(arr: np.ndarray, period: int) -> np.ndarray:
arr = _as_1d(arr); n = len(arr)
out = np.full(n, np.nan, float)
if n == 0: return out
k = 2/(period+1); e = np.nan
for i, x in enumerate(arr):
e = x if (isinstance(e, float) and math.isnan(e)) else x*k + e*(1-k)
out[i] = e
return out
def rsi(arr: np.ndarray, period: int = 14) -> np.ndarray:
arr = _as_1d(arr); n = len(arr)
if n == 0: return np.array([], float)
if n < period+1: return np.full(n, np.nan)
d = np.diff(arr, prepend=arr[0])
g = np.where(d > 0, d, 0.0)
L = np.where(d < 0, -d, 0.0)
ag, al = ema(g, period), ema(L, period)
rs = ag / (al + 1e-9)
return 100 - (100 / (1 + rs))
def atr(H: np.ndarray, L: np.ndarray, C: np.ndarray, period: int = 14) -> np.ndarray:
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0: return np.array([], float)
if n < period+1: return np.full(n, np.nan)
pc = np.roll(C, 1)
tr = np.maximum.reduce([H - L, np.abs(H - pc), np.abs(L - pc)])
tr[0] = H[0] - L[0]
return ema(tr, period)
def macd(arr: np.ndarray, fast=12, slow=26, signal=9):
ef, es = ema(arr, fast), ema(arr, slow)
line = ef - es
sig = ema(line, signal)
hist = line - sig
return line, sig, hist
def stoch_kd(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14, smooth=3):
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0:
z = np.array([], float); return z, z
if n < period:
return np.full(n, np.nan), np.full(n, np.nan)
lowest = pd.Series(L).rolling(period, min_periods=1).min().to_numpy()
highest = pd.Series(H).rolling(period, min_periods=1).max().to_numpy()
k = 100 * (C - lowest) / (highest - lowest + 1e-9)
d = pd.Series(k).rolling(smooth, min_periods=1).mean().to_numpy()
return k, d
def bollinger(arr: np.ndarray, period=20, dev=2.0):
arr = _as_1d(arr); n = len(arr)
if n == 0:
z = np.array([], float); return z, z, z
s = pd.Series(arr)
ma = s.rolling(period, min_periods=1).mean().to_numpy()
sd = s.rolling(period, min_periods=1).std(ddof=0).to_numpy()
return ma, ma + dev*sd, ma - dev*sd
def adx_val(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=14):
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0:
z = np.array([], float); return z, z, z
up_move = H - np.roll(H, 1); down_move = np.roll(L, 1) - L
up_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0)
down_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0)
tr1 = H - L; tr2 = np.abs(H - np.roll(C, 1)); tr3 = np.abs(L - np.roll(C, 1))
tr = np.maximum.reduce([tr1, tr2, tr3]); tr[0] = tr1[0]
atr14 = ema(tr, period)
pdi = 100 * ema(up_dm, period) / (atr14 + 1e-9)
mdi = 100 * ema(down_dm, period) / (atr14 + 1e-9)
dx = 100 * np.abs(pdi - mdi) / (pdi + mdi + 1e-9)
adx = ema(dx, period)
return adx, pdi, mdi
def supertrend(H: np.ndarray, L: np.ndarray, C: np.ndarray, period=10, mult=3.0):
H, L, C = _as_1d(H), _as_1d(L), _as_1d(C); n = len(C)
if n == 0: return np.array([], float)
a = atr(H, L, C, period).copy()
hl2 = (H + L) / 2.0
ub = hl2 + mult * a
lb = hl2 - mult * a
st = np.full(n, np.nan)
for i in range(1, n):
prev = st[i-1] if not np.isnan(st[i-1]) else hl2[i-1]
st[i] = ub[i] if C[i-1] <= prev else lb[i]
return st
# ===== Pipeline: add_indicators =====
def add_indicators(df: pd.DataFrame, min_bars: int = 200) -> pd.DataFrame:
if len(df) < min_bars:
raise ValueError(f"Too few bars: {len(df)} < {min_bars}")
C, H, L = df["close"].to_numpy(), df["high"].to_numpy(), df["low"].to_numpy()
df["ema20"] = ema(C, 20)
df["ema50"] = ema(C, 50)
df["rsi14"] = rsi(C, 14)
df["atr14"] = atr(H, L, C, 14)
m_line, m_sig, _ = macd(C)
df["macd"], df["macd_sig"] = m_line, m_sig
k, d = stoch_kd(H, L, C, 14, 3)
df["stoch_k"], df["stoch_d"] = k, d
mid, up, dn = bollinger(C, 20, 2.0)
df["bb_mid"], df["bb_up"], df["bb_dn"] = mid, up, dn
adx_, pdi, mdi = adx_val(H, L, C, 14)
df["adx"], df["pdi"], df["mdi"] = adx_, pdi, mdi
df["supertrend"] = supertrend(H, L, C, 10, 3.0)
return df

Binary file not shown.

View File

@ -1,333 +0,0 @@
# portfolio.py
from __future__ import annotations
import math, time, json
from typing import Dict, List, Any, Optional
# ========= TRYB KSIĘGOWOŚCI =========
# "stock" akcje (short dodaje gotówkę przy otwarciu, przy zamknięciu oddajesz notional)
# "margin" FX/CFD (short NIE zmienia cash przy otwarciu; cash zmienia się o zrealizowany PnL przy zamknięciu)
ACCOUNTING_MODE = "margin"
# ========= RYZYKO / ATR =========
USE_ATR = True # jeśli sygnał ma "atr" > 0, to SL/TP liczone od ATR
SL_ATR_MULT = 2.0 # SL = 2.0 * ATR
TP_ATR_MULT = 3.0 # TP = 3.0 * ATR
RISK_FRACTION = 0.003 # 0.3% equity na trade (position sizing wg 1R)
# fallback procentowy, gdy brak ATR
SL_PCT = 0.010
TP_PCT = 0.020
TRAIL_PCT = 0.020 # spokojniejszy trailing
SYMBOL_OVERRIDES = {
# "EURUSD=X": {"SL_PCT": 0.0025, "TP_PCT": 0.0050, "TRAIL_PCT": 0.0030},
}
# ========= LIMITY BUDŻETOWE =========
ALLOC_FRACTION = 0.25 # max % gotówki na JEDEN LONG
MIN_TRADE_CASH = 25.0
MAX_NEW_POSITIONS_PER_CYCLE = 2 # None aby wyłączyć limit
# =================================
def _to_native(obj: Any):
"""JSON-safe: NaN/Inf -> None; rekurencyjnie czyści struktury."""
if isinstance(obj, float):
return None if (math.isnan(obj) or math.isinf(obj)) else obj
if isinstance(obj, (int, str)) or obj is None:
return obj
if isinstance(obj, dict):
return {k: _to_native(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_to_native(x) for x in obj]
try:
return float(obj)
except Exception:
return str(obj)
def save_json(path: str, data: Any) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(_to_native(data), f, ensure_ascii=False, indent=2, allow_nan=False)
class Portfolio:
"""
LONG open: cash -= qty*price ; close: cash += qty*price ; PnL = (px - entry)*qty
SHORT open (stock): cash += qty*price ; close: cash -= qty*price ; PnL = (entry - px)*qty
SHORT open (margin): cash bez zmian ; close: cash += PnL ; PnL = (entry - px)*qty
total_value:
- stock -> cash + positions_net (wartość likwidacyjna)
- margin -> start_capital + realized_pnl + unrealized_pnl
"""
def __init__(self, capital: float):
self.cash = float(capital)
self.start_capital = float(capital)
self.positions: Dict[str, Dict] = {}
self.history: List[Dict] = []
self.trade_log: List[Dict] = []
self.realized_pnl: float = 0.0
self.last_prices: Dict[str, float] = {} # ostatnie znane ceny
# ---------- helpers ----------
def _get_params(self, ticker: str):
o = SYMBOL_OVERRIDES.get(ticker, {})
sl = float(o.get("SL_PCT", SL_PCT))
tp = float(o.get("TP_PCT", TP_PCT))
tr = float(o.get("TRAIL_PCT", TRAIL_PCT))
return sl, tp, tr
def _init_risk(self, ticker: str, entry: float, side: int, atr: Optional[float] = None):
"""Zwraca: stop, take, trail_best, trail_stop, one_r."""
sl_pct, tp_pct, trail_pct = self._get_params(ticker)
use_atr = USE_ATR and atr is not None and isinstance(atr, (int, float)) and atr > 0.0
if use_atr:
sl_dist = SL_ATR_MULT * float(atr)
tp_dist = TP_ATR_MULT * float(atr)
stop = entry - sl_dist if side == 1 else entry + sl_dist
take = entry + tp_dist if side == 1 else entry - tp_dist
one_r = sl_dist
else:
if side == 1:
stop = entry * (1.0 - sl_pct); take = entry * (1.0 + tp_pct)
else:
stop = entry * (1.0 + sl_pct); take = entry * (1.0 - tp_pct)
one_r = abs(entry - stop)
trail_best = entry
trail_stop = stop
return stop, take, trail_best, trail_stop, max(one_r, 1e-8)
def _update_trailing(self, ticker: str, pos: Dict, price: float):
"""Breakeven po 1R + trailing procentowy."""
_, _, trail_pct = self._get_params(ticker)
# BE po 1R
if pos["side"] == 1 and not pos.get("be_done", False):
if price >= pos["entry"] + pos["one_r"]:
pos["stop"] = max(pos["stop"], pos["entry"])
pos["be_done"] = True
elif pos["side"] == -1 and not pos.get("be_done", False):
if price <= pos["entry"] - pos["one_r"]:
pos["stop"] = min(pos["stop"], pos["entry"])
pos["be_done"] = True
# trailing
if pos["side"] == 1:
if price > pos["trail_best"]:
pos["trail_best"] = price
pos["trail_stop"] = pos["trail_best"] * (1.0 - trail_pct)
else:
if price < pos["trail_best"]:
pos["trail_best"] = price
pos["trail_stop"] = pos["trail_best"] * (1.0 + trail_pct)
def _positions_values(self, prices: Optional[Dict[str, float]] = None) -> Dict[str, float]:
"""Zwraca Σ|qty*px| oraz Σqty*px*side. Fallback ceny: prices -> self.last_prices -> entry."""
gross = 0.0
net = 0.0
for t, p in self.positions.items():
px = None
if prices is not None:
px = prices.get(t)
if px is None or (isinstance(px, float) and math.isnan(px)):
px = self.last_prices.get(t)
if px is None or (isinstance(px, float) and math.isnan(px)):
px = p["entry"]
gross += abs(p["qty"] * px)
net += p["qty"] * px * p["side"]
return {"positions_gross": gross, "positions_net": net}
def unrealized_pnl(self, prices: Dict[str, float]) -> float:
"""Σ side * (px - entry) * qty."""
upnl = 0.0
for t, p in self.positions.items():
px = prices.get(t, self.last_prices.get(t, p["entry"]))
if px is None or (isinstance(px, float) and math.isnan(px)): px = p["entry"]
upnl += p["side"] * (px - p["entry"]) * p["qty"]
return upnl
def equity_from_start(self, prices: Dict[str, float]) -> float:
return self.start_capital + self.realized_pnl + self.unrealized_pnl(prices)
# ---------- zamknięcia / log ----------
def _close_position(self, ticker: str, price: float, reason: str):
pos = self.positions.get(ticker)
if not pos: return
qty, entry, side = pos["qty"], pos["entry"], pos["side"]
if side == 1:
self.cash += qty * price
pnl_abs = (price - entry) * qty
else:
pnl_abs = (entry - price) * qty
if ACCOUNTING_MODE == "stock":
self.cash -= qty * price
else:
self.cash += pnl_abs # margin: tylko PnL wpływa na cash
denom = max(qty * entry, 1e-12)
pnl_pct = pnl_abs / denom
self.realized_pnl += pnl_abs
self._log_trade("SELL", ticker, price, qty, side, pnl_abs, pnl_pct, reason=reason)
del self.positions[ticker]
def _log_trade(self, action: str, ticker: str, price: float, qty: float, side: int,
pnl_abs: float = 0.0, pnl_pct: float = 0.0, reason: Optional[str] = None):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
if action == "BUY":
print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={'LONG' if side==1 else 'SHORT'}, cash={self.cash:.2f}")
else:
r = f", reason={reason}" if reason else ""
print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), cash={self.cash:.2f}{r}")
self.trade_log.append({
"time": ts, "action": action, "ticker": ticker, "price": float(price),
"qty": float(qty), "side": int(side),
"pnl_abs": float(pnl_abs), "pnl_pct": float(pnl_pct),
"realized_pnl_cum": float(self.realized_pnl), "cash_after": float(self.cash),
"reason": reason or ""
})
# ---------- główna aktualizacja ----------
def on_signals(self, sigs: List[dict]):
# normalizacja
clean: List[Dict[str, Any]] = []
for s in sigs:
if isinstance(s, dict):
px = s.get("price")
if s.get("error") is None and px is not None and not math.isnan(px):
clean.append(s)
else:
if getattr(s, "error", None) is None and not math.isnan(getattr(s, "price", float("nan"))):
clean.append({
"ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time,
"atr": getattr(s, "atr", None)
})
# snapshot, gdy brak świeżych danych
if not clean:
vals = self._positions_values(None)
prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()}
if ACCOUNTING_MODE == "stock":
account_value = self.cash + vals["positions_net"]
else:
account_value = self.equity_from_start(prices_map)
unreal = self.unrealized_pnl(prices_map)
self.history.append({
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"cash": float(self.cash),
"positions_value": float(vals["positions_gross"]),
"positions_net": float(vals["positions_net"]),
"total_value": float(account_value),
"equity_from_start": float(self.start_capital + self.realized_pnl + unreal),
"unrealized_pnl": float(unreal),
"realized_pnl_cum": float(self.realized_pnl),
"open_positions": int(len(self.positions))
})
save_json("portfolio_history.json", self.history)
return
# mapy cen
prices = {s["ticker"]: float(s["price"]) for s in clean}
self.last_prices.update(prices)
# 1) risk exits (SL/TP/TRAIL + BE)
to_close = []
for t, pos in list(self.positions.items()):
price = prices.get(t)
if price is None or math.isnan(price): continue
self._update_trailing(t, pos, price)
if pos["side"] == 1:
if price <= pos["stop"]: to_close.append((t, "SL"))
elif price >= pos["take"]: to_close.append((t, "TP"))
elif price <= pos.get("trail_stop", -float("inf")): to_close.append((t, "TRAIL"))
else:
if price >= pos["stop"]: to_close.append((t, "SL"))
elif price <= pos["take"]: to_close.append((t, "TP"))
elif price >= pos.get("trail_stop", float("inf")): to_close.append((t, "TRAIL"))
for t, reason in to_close:
price = prices.get(t)
if price is not None and not math.isnan(price) and t in self.positions:
self._close_position(t, price, reason=reason)
# 2) zamknięcie na przeciwny/HOLD
for s in clean:
t, sig = s["ticker"], int(s["signal"])
price = float(s["price"])
if t in self.positions:
pos = self.positions[t]
if sig == 0 or sig != pos["side"]:
self._close_position(t, price, reason="SIGNAL")
# 3) otwarcia ATR sizing + limity
candidates = [s for s in clean if s["ticker"] not in self.positions and int(s["signal"]) != 0]
if MAX_NEW_POSITIONS_PER_CYCLE and MAX_NEW_POSITIONS_PER_CYCLE > 0:
candidates = candidates[:MAX_NEW_POSITIONS_PER_CYCLE]
for s in candidates:
t, sig, price = s["ticker"], int(s["signal"]), float(s["price"])
atr = s.get("atr", None)
if price <= 0: continue
stop, take, trail_best, trail_stop, one_r = self._init_risk(t, price, sig, atr)
# equity teraz (dla sizingu ryzyka)
if ACCOUNTING_MODE == "margin":
equity_now = self.start_capital + self.realized_pnl + self.unrealized_pnl(self.last_prices)
else:
vals_tmp = self._positions_values(self.last_prices)
equity_now = self.cash + vals_tmp["positions_net"]
risk_amount = max(1e-6, equity_now * RISK_FRACTION)
qty_risk = risk_amount / max(one_r, 1e-8)
# limit gotówki dla LONG
per_trade_cash = max(MIN_TRADE_CASH, self.cash * ALLOC_FRACTION)
qty_cash = per_trade_cash / max(price, 1e-12) if sig == 1 else float("inf")
qty = max(0.0, min(qty_risk, qty_cash))
if qty <= 0: continue
# księgowanie gotówki przy otwarciu
if sig == 1:
cost = qty * price
if self.cash < cost: continue
self.cash -= cost
else:
if ACCOUNTING_MODE == "stock":
self.cash += qty * price
# margin: brak zmiany cash przy short open
self.positions[t] = {
"qty": qty, "entry": price, "side": sig,
"stop": stop, "take": take,
"trail_best": trail_best, "trail_stop": trail_stop,
"one_r": one_r, "be_done": False
}
self._log_trade("BUY" if sig == 1 else "SELL", t, price, qty, sig)
# 4) snapshot na bieżących/ostatnich cenach
vals = self._positions_values(self.last_prices)
prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()}
if ACCOUNTING_MODE == "stock":
account_value = self.cash + vals["positions_net"]
else:
account_value = self.equity_from_start(prices_map)
unreal = self.unrealized_pnl(prices_map)
self.history.append({
"time": clean[0]["time"],
"cash": float(self.cash),
"positions_value": float(vals["positions_gross"]),
"positions_net": float(vals["positions_net"]),
"total_value": float(account_value),
"equity_from_start": float(self.start_capital + self.realized_pnl + unreal),
"unrealized_pnl": float(unreal),
"realized_pnl_cum": float(self.realized_pnl),
"open_positions": int(len(self.positions))
})
# 5) zapisy
save_json("trade_log.json", self.trade_log)
save_json("portfolio_history.json", self.history)
save_json("positions.json", [{"ticker": t, **p} for t, p in self.positions.items()])

View File

@ -1,79 +0,0 @@
[
{
"time": "2025-08-15 10:11:00+00:00",
"cash": 10000.0,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 0
},
{
"time": "2025-08-15 10:12:00+00:00",
"cash": 10000.0,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 0
},
{
"time": "2025-08-15 10:13:00+00:00",
"cash": 10000.0,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 0
},
{
"time": "2025-08-15 10:14:00+00:00",
"cash": 10000.0,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 0
},
{
"time": "2025-08-15 10:15:00+00:00",
"cash": 10000.0,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 0
},
{
"time": "2025-08-15 10:16:00+00:00",
"cash": 10000.0,
"positions_value": 5999.999999999975,
"positions_net": -5999.999999999975,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 2
},
{
"time": "2025-08-15 10:17:00+00:00",
"cash": 10000.0,
"positions_value": 5999.999999999975,
"positions_net": -5999.999999999975,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 2
}
]

View File

@ -1,26 +0,0 @@
[
{
"ticker": "OP-USD",
"qty": 3973.896468582607,
"entry": 0.7549265623092651,
"side": -1,
"stop": 0.7624758279323578,
"take": 0.7398280310630798,
"trail_best": 0.7549265623092651,
"trail_stop": 0.7624758279323578,
"one_r": 0.00754926562309266,
"be_done": false
},
{
"ticker": "NEAR-USD",
"qty": 1076.5240904642392,
"entry": 2.7867467403411865,
"side": -1,
"stop": 2.8146142077445986,
"take": 2.731011805534363,
"trail_best": 2.7867467403411865,
"trail_stop": 2.8146142077445986,
"one_r": 0.02786746740341206,
"be_done": false
}
]

View File

@ -1,221 +0,0 @@
// static/app.js
const fmt2 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(2));
const fmt6 = (x) => (x == null || isNaN(x) ? "—" : Number(x).toFixed(6));
const fmtPct= (x) => (x == null || isNaN(x) ? "—" : (Number(x) * 100).toFixed(2) + "%");
async function getJSON(url) {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.json();
}
// prosty wykres linii na canvas (bez bibliotek)
function drawLineChart(canvas, points) {
const ctx = canvas.getContext("2d");
const pad = 32;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
if (!points || points.length === 0) {
ctx.fillStyle = "#9aa3b2";
ctx.fillText("Brak danych", 10, 20);
return;
}
const n = points.length;
const ys = points.map(p => p.y);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const yLo = minY === maxY ? minY - 1 : minY;
const yHi = minY === maxY ? maxY + 1 : maxY;
const x0 = pad, y0 = h - pad, x1 = w - pad, y1 = pad;
const xScale = (i) => x0 + (i / (n - 1)) * (x1 - x0);
const yScale = (y) => y0 - ((y - yLo) / (yHi - yLo)) * (y0 - y1);
// osie
ctx.strokeStyle = "#242a36";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0, y0); ctx.lineTo(x1, y0);
ctx.moveTo(x0, y0); ctx.lineTo(x0, y1);
ctx.stroke();
// siatka
ctx.strokeStyle = "#1b2130";
[0.25, 0.5, 0.75].forEach(f => {
const yy = y0 - (y0 - y1) * f;
ctx.beginPath();
ctx.moveTo(x0, yy); ctx.lineTo(x1, yy);
ctx.stroke();
});
// podpisy min/max
ctx.fillStyle = "#9aa3b2";
ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial";
ctx.fillText(fmt2(yHi), 6, y1 + 10);
ctx.fillText(fmt2(yLo), 6, y0 - 2);
// linia
ctx.strokeStyle = "#4da3ff";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xScale(0), yScale(ys[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xScale(i), yScale(ys[i]));
ctx.stroke();
// kropka na końcu
const lastX = xScale(n - 1), lastY = yScale(ys[n - 1]);
ctx.fillStyle = "#e7e9ee";
ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillText(fmt2(ys[n - 1]), lastX + 6, lastY - 6);
}
async function loadAll() {
try {
const [snap, hist, pos, trades] = await Promise.all([
getJSON("/api/snapshot"),
getJSON("/api/history"),
getJSON("/api/positions"),
getJSON("/api/trades"),
]);
// czas
document.getElementById("last-update").textContent =
"Ostatnia aktualizacja: " + (snap?.last_history?.time || "—");
// ===== ostatni wiersz historii =====
const last = Array.isArray(hist) && hist.length ? hist[hist.length - 1] : null;
const cashVal = Number(last?.cash ?? 0);
const totalVal = Number(
last?.total_value ??
(Number(last?.cash ?? 0) + Number(last?.positions_net ?? 0)) // fallback dla starszych logów
);
// KARTY
document.getElementById("cash").textContent = fmt2(cashVal);
document.getElementById("total-value").textContent = fmt2(totalVal);
// mapa ostatnich cen do liczenia zysku (unrealized)
const lastPrice = new Map();
(snap?.signals || []).forEach(s => {
const px = Number(s.price);
if (!isNaN(px)) lastPrice.set(s.ticker, px);
});
// Zysk = suma niezrealizowanych PnL na otwartych pozycjach
let unrealPnL = 0;
(pos || []).forEach(p => {
const price = Number(lastPrice.get(p.ticker));
const entry = Number(p.entry);
const qty = Number(p.qty);
const side = Number(p.side);
if (isNaN(price) || isNaN(entry) || isNaN(qty) || isNaN(side)) return;
unrealPnL += side === 1 ? (price - entry) * qty : (entry - price) * qty;
});
const unrlEl = document.getElementById("unrealized");
unrlEl.textContent = fmt2(unrealPnL);
unrlEl.classList.remove("pnl-positive", "pnl-negative");
if (unrealPnL > 0) unrlEl.classList.add("pnl-positive");
else if (unrealPnL < 0) unrlEl.classList.add("pnl-negative");
// liczba otwartych pozycji
document.getElementById("open-pos").textContent =
Number(last?.open_positions ?? (pos?.length ?? 0));
// ===== WYKRES WARTOŚCI KONTA (TOTAL) =====
const totalCanvas = document.getElementById("totalChart");
const totalPoints = (hist || [])
.map((row, i) => {
const v = (row.total_value != null)
? Number(row.total_value)
: Number(row.cash ?? 0) + Number(row.positions_net ?? 0);
return { x: i, y: v };
})
.filter(p => !isNaN(p.y))
.slice(-500);
drawLineChart(totalCanvas, totalPoints);
// ===== WYKRES GOTÓWKI (CASH) =====
const cashCanvas = document.getElementById("cashChart");
const cashPoints = (hist || [])
.map((row, i) => ({ x: i, y: Number(row.cash ?? NaN) }))
.filter(p => !isNaN(p.y))
.slice(-500);
drawLineChart(cashCanvas, cashPoints);
// ===== POZYCJE =====
const posBody = document.querySelector("#positions-table tbody");
posBody.innerHTML = "";
(pos || []).forEach((p) => {
const price = Number(lastPrice.get(p.ticker));
const entry = Number(p.entry);
const qty = Number(p.qty);
const side = Number(p.side);
let upnl = NaN, upct = NaN;
if (!isNaN(price) && !isNaN(entry) && !isNaN(qty) && !isNaN(side)) {
upnl = side === 1 ? (price - entry) * qty : (entry - price) * qty;
const denom = Math.max(qty * entry, 1e-12);
upct = upnl / denom;
}
const pnlClass = upnl > 0 ? "pnl-positive" : (upnl < 0 ? "pnl-negative" : "");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.ticker}</td>
<td>${fmt6(qty)}</td>
<td>${fmt6(entry)}</td>
<td>${side === 1 ? "LONG" : "SHORT"}</td>
<td>${fmt6(price)}</td>
<td class="${pnlClass}">${fmt2(upnl)}</td>
<td class="${pnlClass}">${fmtPct(upct)}</td>
`;
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 = `
<td>${s.ticker}</td>
<td>${s.time}</td>
<td>${fmt6(s.price)}</td>
<td class="${sigTxt}">${sigTxt}</td>
<td>${s.interval}</td>
`;
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 = `
<td>${t.time}</td>
<td class="${t.action}">${t.action}</td>
<td>${t.ticker}</td>
<td>${fmt6(t.price)}</td>
<td>${fmt6(t.qty)}</td>
<td class="${pnlClass}">${fmt2(t.pnl_abs)}</td>
<td class="${pnlClass}">${fmtPct(t.pnl_pct)}</td>
<td>${fmt2(t.cash_after)}</td>
`;
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);

View File

@ -1,114 +0,0 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Trading Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<header>
<h1>Trading Dashboard</h1>
<div class="meta">
<span id="last-update">Ostatnia aktualizacja: —</span>
<button id="refresh-btn">Odśwież teraz</button>
</div>
</header>
<main>
<!-- KARTY -->
<section class="cards">
<div class="card">
<div class="card-title">Gotówka</div>
<div class="card-value" id="cash"></div>
</div>
<div class="card">
<div class="card-title">Wartość konta (total)</div>
<div class="card-value" id="total-value"></div>
</div>
<div class="card">
<div class="card-title">Zysk (otwarte pozycje)</div>
<div class="card-value" id="unrealized"></div>
</div>
<div class="card">
<div class="card-title">Otwarte pozycje</div>
<div class="card-value" id="open-pos"></div>
</div>
</section>
<!-- WYKRES WARTOŚCI KONTA (TOTAL) -->
<section>
<h2>Wartość konta (total) — wykres</h2>
<div class="chart-wrap">
<canvas id="totalChart" width="1200" height="300"></canvas>
</div>
</section>
<!-- WYKRES GOTÓWKI -->
<section>
<h2>Gotówka — wykres</h2>
<div class="chart-wrap">
<canvas id="cashChart" width="1200" height="300"></canvas>
</div>
</section>
<!-- Otwarte pozycje -->
<section>
<h2>Otwarte pozycje</h2>
<table id="positions-table">
<thead>
<tr>
<th>Ticker</th>
<th>Qty</th>
<th>Entry</th>
<th>Side</th>
<th>Last price</th>
<th>Unreal. PnL</th>
<th>Unreal. PnL %</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Ostatnie sygnały -->
<section>
<h2>Ostatnie sygnały</h2>
<table id="signals-table">
<thead>
<tr>
<th>Ticker</th>
<th>Time</th>
<th>Price</th>
<th>Signal</th>
<th>Interval</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<!-- Transakcje -->
<section>
<h2>Transakcje (ostatnie 50)</h2>
<table id="trades-table">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Ticker</th>
<th>Price</th>
<th>Qty</th>
<th>PnL</th>
<th>PnL %</th>
<th>Cash po</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
</main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

View File

@ -1,28 +0,0 @@
[
{
"time": "2025-08-15 12:18:01",
"action": "SELL",
"ticker": "OP-USD",
"price": 0.7549265623092651,
"qty": 3973.896468582607,
"side": -1,
"pnl_abs": 0.0,
"pnl_pct": 0.0,
"realized_pnl_cum": 0.0,
"cash_after": 10000.0,
"reason": ""
},
{
"time": "2025-08-15 12:18:01",
"action": "SELL",
"ticker": "NEAR-USD",
"price": 2.7867467403411865,
"qty": 1076.5240904642392,
"side": -1,
"pnl_abs": 0.0,
"pnl_pct": 0.0,
"realized_pnl_cum": 0.0,
"cash_after": 10000.0,
"reason": ""
}
]

View File

@ -1,11 +1,39 @@
# portfolio.py
from __future__ import annotations
import math, time, json
from typing import Dict, List, Any
from typing import Dict, List, Any, Optional
# ========= TRYB KSIĘGOWOŚCI =========
# "stock" akcje (short dodaje gotówkę przy otwarciu, przy zamknięciu oddajesz notional)
# "margin" FX/CFD (short NIE zmienia cash przy otwarciu; cash zmienia się o zrealizowany PnL przy zamknięciu)
ACCOUNTING_MODE = "margin"
# ========= RYZYKO / ATR =========
USE_ATR = True # jeśli sygnał ma "atr" > 0, to SL/TP liczone od ATR
SL_ATR_MULT = 2.0 # SL = 2.0 * ATR
TP_ATR_MULT = 3.0 # TP = 3.0 * ATR
RISK_FRACTION = 0.003 # 0.3% equity na trade (position sizing wg 1R)
# fallback procentowy, gdy brak ATR
SL_PCT = 0.010
TP_PCT = 0.020
TRAIL_PCT = 0.020 # spokojniejszy trailing
SYMBOL_OVERRIDES = {
# "EURUSD=X": {"SL_PCT": 0.0025, "TP_PCT": 0.0050, "TRAIL_PCT": 0.0030},
}
# ========= LIMITY BUDŻETOWE =========
ALLOC_FRACTION = 0.25 # max % gotówki na JEDEN LONG
MIN_TRADE_CASH = 25.0
MAX_NEW_POSITIONS_PER_CYCLE = 2 # None aby wyłączyć limit
# =================================
def _to_native(obj: Any):
"""Bezpieczne rzutowanie do typów akceptowalnych przez JSON."""
if isinstance(obj, (float, int, str)) or obj is None:
"""JSON-safe: NaN/Inf -> None; rekurencyjnie czyści struktury."""
if isinstance(obj, float):
return None if (math.isnan(obj) or math.isinf(obj)) else obj
if isinstance(obj, (int, str)) or obj is None:
return obj
if isinstance(obj, dict):
return {k: _to_native(v) for k, v in obj.items()}
@ -18,16 +46,17 @@ def _to_native(obj: Any):
def save_json(path: str, data: Any) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(_to_native(data), f, ensure_ascii=False, indent=2)
json.dump(_to_native(data), f, ensure_ascii=False, indent=2, allow_nan=False)
class Portfolio:
"""
Prosta symulacja portfela:
- cash
- positions: {ticker: {"qty": float, "entry": float, "side": int}}
- equity = cash + niezrealizowany PnL
Logi transakcji z realized PnL (wartość i %), plus PnL skumulowany.
Zapis tylko w JSON.
LONG open: cash -= qty*price ; close: cash += qty*price ; PnL = (px - entry)*qty
SHORT open (stock): cash += qty*price ; close: cash -= qty*price ; PnL = (entry - px)*qty
SHORT open (margin): cash bez zmian ; close: cash += PnL ; PnL = (entry - px)*qty
total_value:
- stock -> cash + positions_net (wartość likwidacyjna)
- margin -> start_capital + realized_pnl + unrealized_pnl
"""
def __init__(self, capital: float):
self.cash = float(capital)
@ -35,103 +64,270 @@ class Portfolio:
self.positions: Dict[str, Dict] = {}
self.history: List[Dict] = []
self.trade_log: List[Dict] = []
self.realized_pnl: float = 0.0 # skumulowany realized PnL
self.realized_pnl: float = 0.0
self.last_prices: Dict[str, float] = {} # ostatnie znane ceny
def mark_to_market(self, prices: Dict[str, float]) -> float:
unreal = 0.0
# ---------- helpers ----------
def _get_params(self, ticker: str):
o = SYMBOL_OVERRIDES.get(ticker, {})
sl = float(o.get("SL_PCT", SL_PCT))
tp = float(o.get("TP_PCT", TP_PCT))
tr = float(o.get("TRAIL_PCT", TRAIL_PCT))
return sl, tp, tr
def _init_risk(self, ticker: str, entry: float, side: int, atr: Optional[float] = None):
"""Zwraca: stop, take, trail_best, trail_stop, one_r."""
sl_pct, tp_pct, trail_pct = self._get_params(ticker)
use_atr = USE_ATR and atr is not None and isinstance(atr, (int, float)) and atr > 0.0
if use_atr:
sl_dist = SL_ATR_MULT * float(atr)
tp_dist = TP_ATR_MULT * float(atr)
stop = entry - sl_dist if side == 1 else entry + sl_dist
take = entry + tp_dist if side == 1 else entry - tp_dist
one_r = sl_dist
else:
if side == 1:
stop = entry * (1.0 - sl_pct); take = entry * (1.0 + tp_pct)
else:
stop = entry * (1.0 + sl_pct); take = entry * (1.0 - tp_pct)
one_r = abs(entry - stop)
trail_best = entry
trail_stop = stop
return stop, take, trail_best, trail_stop, max(one_r, 1e-8)
def _update_trailing(self, ticker: str, pos: Dict, price: float):
"""Breakeven po 1R + trailing procentowy."""
_, _, trail_pct = self._get_params(ticker)
# BE po 1R
if pos["side"] == 1 and not pos.get("be_done", False):
if price >= pos["entry"] + pos["one_r"]:
pos["stop"] = max(pos["stop"], pos["entry"])
pos["be_done"] = True
elif pos["side"] == -1 and not pos.get("be_done", False):
if price <= pos["entry"] - pos["one_r"]:
pos["stop"] = min(pos["stop"], pos["entry"])
pos["be_done"] = True
# trailing
if pos["side"] == 1:
if price > pos["trail_best"]:
pos["trail_best"] = price
pos["trail_stop"] = pos["trail_best"] * (1.0 - trail_pct)
else:
if price < pos["trail_best"]:
pos["trail_best"] = price
pos["trail_stop"] = pos["trail_best"] * (1.0 + trail_pct)
def _positions_values(self, prices: Optional[Dict[str, float]] = None) -> Dict[str, float]:
"""Zwraca Σ|qty*px| oraz Σqty*px*side. Fallback ceny: prices -> self.last_prices -> entry."""
gross = 0.0
net = 0.0
for t, p in self.positions.items():
price = prices.get(t)
if price is not None and not math.isnan(price):
unreal += (price - p["entry"]) * p["qty"] * p["side"]
return self.cash + unreal
px = None
if prices is not None:
px = prices.get(t)
if px is None or (isinstance(px, float) and math.isnan(px)):
px = self.last_prices.get(t)
if px is None or (isinstance(px, float) and math.isnan(px)):
px = p["entry"]
gross += abs(p["qty"] * px)
net += p["qty"] * px * p["side"]
return {"positions_gross": gross, "positions_net": net}
def unrealized_pnl(self, prices: Dict[str, float]) -> float:
"""Σ side * (px - entry) * qty."""
upnl = 0.0
for t, p in self.positions.items():
px = prices.get(t, self.last_prices.get(t, p["entry"]))
if px is None or (isinstance(px, float) and math.isnan(px)): px = p["entry"]
upnl += p["side"] * (px - p["entry"]) * p["qty"]
return upnl
def equity_from_start(self, prices: Dict[str, float]) -> float:
return self.start_capital + self.realized_pnl + self.unrealized_pnl(prices)
# ---------- zamknięcia / log ----------
def _close_position(self, ticker: str, price: float, reason: str):
pos = self.positions.get(ticker)
if not pos: return
qty, entry, side = pos["qty"], pos["entry"], pos["side"]
if side == 1:
self.cash += qty * price
pnl_abs = (price - entry) * qty
else:
pnl_abs = (entry - price) * qty
if ACCOUNTING_MODE == "stock":
self.cash -= qty * price
else:
self.cash += pnl_abs # margin: tylko PnL wpływa na cash
denom = max(qty * entry, 1e-12)
pnl_pct = pnl_abs / denom
self.realized_pnl += pnl_abs
self._log_trade("SELL", ticker, price, qty, side, pnl_abs, pnl_pct, reason=reason)
del self.positions[ticker]
def _log_trade(self, action: str, ticker: str, price: float, qty: float, side: int,
pnl_abs: float = 0.0, pnl_pct: float = 0.0):
pnl_abs: float = 0.0, pnl_pct: float = 0.0, reason: Optional[str] = None):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
side_str = "LONG" if side == 1 else "SHORT"
if action == "BUY":
print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={side_str}, cash={self.cash:.2f}")
elif action == "SELL":
print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, "
f"PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), "
f"cumPnL={self.realized_pnl:+.2f}, cash={self.cash:.2f}")
# zapis do logu w pamięci
print(f"[{ts}] BUY {ticker} @ {price:.6f}, qty={qty:.6f}, side={'LONG' if side==1 else 'SHORT'}, cash={self.cash:.2f}")
else:
r = f", reason={reason}" if reason else ""
print(f"[{ts}] SELL {ticker} @ {price:.6f}, qty={qty:.6f}, PnL={pnl_abs:+.2f} ({pnl_pct:+.2%}), cash={self.cash:.2f}{r}")
self.trade_log.append({
"time": ts,
"action": action,
"ticker": ticker,
"price": float(price),
"qty": float(qty),
"side": int(side),
"pnl_abs": float(pnl_abs),
"pnl_pct": float(pnl_pct),
"realized_pnl_cum": float(self.realized_pnl),
"cash_after": float(self.cash)
"time": ts, "action": action, "ticker": ticker, "price": float(price),
"qty": float(qty), "side": int(side),
"pnl_abs": float(pnl_abs), "pnl_pct": float(pnl_pct),
"realized_pnl_cum": float(self.realized_pnl), "cash_after": float(self.cash),
"reason": reason or ""
})
# ---------- główna aktualizacja ----------
def on_signals(self, sigs: List[dict]):
"""
sigs: lista dictów/obiektów z polami: ticker, price, signal, time
BUY (1) / SELL (-1) / HOLD (0)
"""
clean = []
# normalizacja
clean: List[Dict[str, Any]] = []
for s in sigs:
if isinstance(s, dict):
if s.get("error") is None and s.get("price") is not None and not math.isnan(s.get("price", float("nan"))):
px = s.get("price")
if s.get("error") is None and px is not None and not math.isnan(px):
clean.append(s)
else:
if getattr(s, "error", None) is None and not math.isnan(getattr(s, "price", float("nan"))):
clean.append({"ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time})
clean.append({
"ticker": s.ticker, "price": s.price, "signal": s.signal, "time": s.time,
"atr": getattr(s, "atr", None)
})
# snapshot, gdy brak świeżych danych
if not clean:
vals = self._positions_values(None)
prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()}
if ACCOUNTING_MODE == "stock":
account_value = self.cash + vals["positions_net"]
else:
account_value = self.equity_from_start(prices_map)
unreal = self.unrealized_pnl(prices_map)
self.history.append({
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
"equity": self.cash,
"cash": self.cash,
"open_positions": len(self.positions)
"cash": float(self.cash),
"positions_value": float(vals["positions_gross"]),
"positions_net": float(vals["positions_net"]),
"total_value": float(account_value),
"equity_from_start": float(self.start_capital + self.realized_pnl + unreal),
"unrealized_pnl": float(unreal),
"realized_pnl_cum": float(self.realized_pnl),
"open_positions": int(len(self.positions))
})
save_json("portfolio_history.json", self.history)
return
prices = {s["ticker"]: s["price"] for s in clean}
n = len(clean)
per_trade_cash = max(self.cash / (n * 2), 0.0)
# mapy cen
prices = {s["ticker"]: float(s["price"]) for s in clean}
self.last_prices.update(prices)
# 1) risk exits (SL/TP/TRAIL + BE)
to_close = []
for t, pos in list(self.positions.items()):
price = prices.get(t)
if price is None or math.isnan(price): continue
self._update_trailing(t, pos, price)
if pos["side"] == 1:
if price <= pos["stop"]: to_close.append((t, "SL"))
elif price >= pos["take"]: to_close.append((t, "TP"))
elif price <= pos.get("trail_stop", -float("inf")): to_close.append((t, "TRAIL"))
else:
if price >= pos["stop"]: to_close.append((t, "SL"))
elif price <= pos["take"]: to_close.append((t, "TP"))
elif price >= pos.get("trail_stop", float("inf")): to_close.append((t, "TRAIL"))
for t, reason in to_close:
price = prices.get(t)
if price is not None and not math.isnan(price) and t in self.positions:
self._close_position(t, price, reason=reason)
# 2) zamknięcie na przeciwny/HOLD
for s in clean:
t, sig, price = s["ticker"], int(s["signal"]), float(s["price"])
# zamknięcie lub odwrócenie
t, sig = s["ticker"], int(s["signal"])
price = float(s["price"])
if t in self.positions:
pos = self.positions[t]
if sig == 0 or sig != pos["side"]:
qty = pos["qty"]
entry = pos["entry"]
side = pos["side"]
self._close_position(t, price, reason="SIGNAL")
# 3) otwarcia ATR sizing + limity
candidates = [s for s in clean if s["ticker"] not in self.positions and int(s["signal"]) != 0]
if MAX_NEW_POSITIONS_PER_CYCLE and MAX_NEW_POSITIONS_PER_CYCLE > 0:
candidates = candidates[:MAX_NEW_POSITIONS_PER_CYCLE]
for s in candidates:
t, sig, price = s["ticker"], int(s["signal"]), float(s["price"])
atr = s.get("atr", None)
if price <= 0: continue
stop, take, trail_best, trail_stop, one_r = self._init_risk(t, price, sig, atr)
# equity teraz (dla sizingu ryzyka)
if ACCOUNTING_MODE == "margin":
equity_now = self.start_capital + self.realized_pnl + self.unrealized_pnl(self.last_prices)
else:
vals_tmp = self._positions_values(self.last_prices)
equity_now = self.cash + vals_tmp["positions_net"]
risk_amount = max(1e-6, equity_now * RISK_FRACTION)
qty_risk = risk_amount / max(one_r, 1e-8)
# limit gotówki dla LONG
per_trade_cash = max(MIN_TRADE_CASH, self.cash * ALLOC_FRACTION)
qty_cash = per_trade_cash / max(price, 1e-12) if sig == 1 else float("inf")
qty = max(0.0, min(qty_risk, qty_cash))
if qty <= 0: continue
# księgowanie gotówki przy otwarciu
if sig == 1:
cost = qty * price
if self.cash < cost: continue
self.cash -= cost
else:
if ACCOUNTING_MODE == "stock":
self.cash += qty * price
pnl_abs = (price - entry) * qty * side
denom = max(qty * entry, 1e-12)
pnl_pct = pnl_abs / denom
self.realized_pnl += pnl_abs
self._log_trade("SELL", t, price, qty, side, pnl_abs, pnl_pct)
del self.positions[t]
# margin: brak zmiany cash przy short open
# otwarcie
if t not in self.positions and sig != 0 and per_trade_cash > 0:
qty = per_trade_cash / price
self.cash -= qty * price
self.positions[t] = {"qty": qty, "entry": price, "side": sig}
self._log_trade("BUY", t, price, qty, sig)
self.positions[t] = {
"qty": qty, "entry": price, "side": sig,
"stop": stop, "take": take,
"trail_best": trail_best, "trail_stop": trail_stop,
"one_r": one_r, "be_done": False
}
self._log_trade("BUY" if sig == 1 else "SELL", t, price, qty, sig)
# 4) snapshot na bieżących/ostatnich cenach
vals = self._positions_values(self.last_prices)
prices_map = {t: self.last_prices.get(t, p["entry"]) for t, p in self.positions.items()}
if ACCOUNTING_MODE == "stock":
account_value = self.cash + vals["positions_net"]
else:
account_value = self.equity_from_start(prices_map)
unreal = self.unrealized_pnl(prices_map)
equity = self.mark_to_market(prices)
self.history.append({
"time": clean[0]["time"],
"equity": float(equity),
"cash": float(self.cash),
"open_positions": int(len(self.positions)),
"realized_pnl_cum": float(self.realized_pnl)
"positions_value": float(vals["positions_gross"]),
"positions_net": float(vals["positions_net"]),
"total_value": float(account_value),
"equity_from_start": float(self.start_capital + self.realized_pnl + unreal),
"unrealized_pnl": float(unreal),
"realized_pnl_cum": float(self.realized_pnl),
"open_positions": int(len(self.positions))
})
# zapis plików JSON
# 5) zapisy
save_json("trade_log.json", self.trade_log)
save_json("portfolio_history.json", self.history)
save_json("positions.json", [{"ticker": t, **p} for t, p in self.positions.items()])

24
portfolio_history.json Normal file
View File

@ -0,0 +1,24 @@
[
{
"time": "2025-08-15 10:29:00+00:00",
"cash": 10000.0,
"positions_value": 2999.9999999999804,
"positions_net": -2999.9999999999804,
"total_value": 10000.0,
"equity_from_start": 10000.0,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 1
},
{
"time": "2025-08-15 10:30:00+00:00",
"cash": 9996.027808030158,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 9996.027808030158,
"equity_from_start": 9996.027808030158,
"unrealized_pnl": 0.0,
"realized_pnl_cum": -3.972191969841845,
"open_positions": 0
}
]

1
positions.json Normal file
View File

@ -0,0 +1 @@
[]

Binary file not shown.

View File

@ -1,4 +0,0 @@
yfinance
pandas
numpy
Flask

View File

@ -1,83 +0,0 @@
from __future__ import annotations
import logging
from flask import Flask, jsonify, render_template, request
from trader import TraderWorker
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
)
log = logging.getLogger("server")
app = Flask(__name__, template_folder="templates", static_folder="static")
worker = TraderWorker()
@app.before_request
def _log_request():
log.debug("REQ %s %s args=%s json=%s",
request.method, request.path, dict(request.args), request.get_json(silent=True))
@app.after_request
def _after(resp):
resp.headers["Cache-Control"] = "no-store, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
log.debug("RESP %s %s %s", request.method, request.path, resp.status)
return resp
@app.get("/")
def index():
return render_template("index.html")
@app.get("/api/status")
def api_status():
return jsonify(worker.status())
@app.get("/api/positions")
def api_positions():
return jsonify({"positions": worker.list_positions()})
@app.get("/api/trades")
def api_trades():
return jsonify({"trades": worker.list_trades()})
@app.get("/api/equity")
def api_equity():
return jsonify({"equity": worker.list_equity()})
@app.post("/api/start")
def api_start():
ok = worker.start()
return jsonify({"started": ok, "running": worker.is_running()})
@app.post("/api/stop")
def api_stop():
ok = worker.stop()
return jsonify({"stopped": ok, "running": worker.is_running()})
@app.post("/api/run-once")
def api_run_once():
took = worker.tick_once()
return jsonify({"ok": True, "took_s": took})
# testowe (opcjonalne)
@app.post("/api/test/long")
def api_test_long():
data = request.get_json(silent=True) or {}
worker.test_open_long(data.get("ticker","AAPL"), data.get("price",123.45), data.get("size",1.0))
return jsonify({"ok": True})
@app.post("/api/test/short")
def api_test_short():
data = request.get_json(silent=True) or {}
worker.test_open_short(data.get("ticker","AAPL"), data.get("price",123.45), data.get("size",1.0))
return jsonify({"ok": True})
@app.post("/api/test/close")
def api_test_close():
data = request.get_json(silent=True) or {}
worker.test_close(data.get("ticker","AAPL"), data.get("price"))
return jsonify({"ok": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=False)

View File

@ -1,71 +1,8 @@
[
{
"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,
"time": "2025-08-15 10:30:00+00:00",
"price": 1.1689070463180542,
"signal": 0,
"period": "7d",
"interval": "1m",
@ -73,215 +10,8 @@
},
{
"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,
"time": "2025-08-15 10:31:00+00:00",
"price": 0.8058599829673767,
"signal": 0,
"period": "7d",
"interval": "1m",
@ -289,44 +19,35 @@
},
{
"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,
"time": "2025-08-15 10:21:00+00:00",
"price": 2.7824549674987793,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "XRP-USD",
"time": "2025-08-15 10:16:00+00:00",
"price": 3.111118793487549,
"ticker": "USDJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 146.8820037841797,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURCHF=X",
"time": "2025-08-15 10:18:00+00:00",
"price": 0.9409899711608887,
"ticker": "ETH-USD",
"time": "2025-08-15 10:28:00+00:00",
"price": 4645.15771484375,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "LINK-USD",
"time": "2025-08-15 10:16:00+00:00",
"price": 22.371389389038086,
"ticker": "CADJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 106.47799682617188,
"signal": 0,
"period": "7d",
"interval": "1m",
@ -334,8 +55,287 @@
},
{
"ticker": "AUDUSD=X",
"time": "2025-08-15 10:18:00+00:00",
"price": 0.6509992480278015,
"time": "2025-08-15 10:31:00+00:00",
"price": 0.6507663130760193,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "OP-USD",
"time": "2025-08-15 10:26:00+00:00",
"price": 0.7553843259811401,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 171.62600708007812,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "NZDUSD=X",
"time": "2025-08-15 10:30:00+00:00",
"price": 0.5924872159957886,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "ETC-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 22.407447814941406,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GC=F",
"time": "2025-08-15 10:21:00+00:00",
"price": 3386.89990234375,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GBPJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 199.0590057373047,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "AUDJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 95.55599975585938,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "TRX-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 0.35983800888061523,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "TON-USD",
"time": "2025-08-15 10:26:00+00:00",
"price": 0.01708882860839367,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "LTC-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 121.18510437011719,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURCAD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.6117500066757202,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "AUDNZD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.0984100103378296,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "USDCAD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.3794000148773193,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "ATOM-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 4.5298662185668945,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "BCH-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 595.4796142578125,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "LINK-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 22.34357452392578,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "XRP-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 3.117891550064087,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "DOT-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 4.020590305328369,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "BTC-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 119062.5703125,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GBPUSD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.3552889823913574,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "CHFJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 182.2550048828125,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "ADA-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 0.9589924216270447,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "XLM-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 0.42904016375541687,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURCHF=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 0.9415299892425537,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "BNB-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 845.3945922851562,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "DOGE-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 0.23236910998821259,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "NZDJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 86.98799896240234,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURGBP=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 0.8621399998664856,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "SOL-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 195.72109985351562,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURAUD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.7960000038146973,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GBPCAD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.869420051574707,
"signal": 0,
"period": "7d",
"interval": "1m",

View File

@ -1,110 +1,22 @@
{
"time": "2025-08-15 12:19:02",
"time": "2025-08-15 12:32:18",
"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,
"time": "2025-08-15 10:30:00+00:00",
"cash": 9996.027808030158,
"positions_value": 0.0,
"positions_net": 0.0,
"total_value": 9996.027808030158,
"equity_from_start": 9996.027808030158,
"unrealized_pnl": 0.0,
"realized_pnl_cum": 0.0,
"open_positions": 2
"realized_pnl_cum": -3.972191969841845,
"open_positions": 0
},
"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
}
],
"positions": [],
"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,
"time": "2025-08-15 10:30:00+00:00",
"price": 1.1689070463180542,
"signal": 0,
"period": "7d",
"interval": "1m",
@ -112,215 +24,8 @@
},
{
"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,
"time": "2025-08-15 10:31:00+00:00",
"price": 0.8058599829673767,
"signal": 0,
"period": "7d",
"interval": "1m",
@ -328,44 +33,35 @@
},
{
"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,
"time": "2025-08-15 10:21:00+00:00",
"price": 2.7824549674987793,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "XRP-USD",
"time": "2025-08-15 10:16:00+00:00",
"price": 3.111118793487549,
"ticker": "USDJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 146.8820037841797,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURCHF=X",
"time": "2025-08-15 10:18:00+00:00",
"price": 0.9409899711608887,
"ticker": "ETH-USD",
"time": "2025-08-15 10:28:00+00:00",
"price": 4645.15771484375,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "LINK-USD",
"time": "2025-08-15 10:16:00+00:00",
"price": 22.371389389038086,
"ticker": "CADJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 106.47799682617188,
"signal": 0,
"period": "7d",
"interval": "1m",
@ -373,8 +69,287 @@
},
{
"ticker": "AUDUSD=X",
"time": "2025-08-15 10:18:00+00:00",
"price": 0.6509992480278015,
"time": "2025-08-15 10:31:00+00:00",
"price": 0.6507663130760193,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "OP-USD",
"time": "2025-08-15 10:26:00+00:00",
"price": 0.7553843259811401,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 171.62600708007812,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "NZDUSD=X",
"time": "2025-08-15 10:30:00+00:00",
"price": 0.5924872159957886,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "ETC-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 22.407447814941406,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GC=F",
"time": "2025-08-15 10:21:00+00:00",
"price": 3386.89990234375,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GBPJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 199.0590057373047,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "AUDJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 95.55599975585938,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "TRX-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 0.35983800888061523,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "TON-USD",
"time": "2025-08-15 10:26:00+00:00",
"price": 0.01708882860839367,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "LTC-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 121.18510437011719,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURCAD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.6117500066757202,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "AUDNZD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.0984100103378296,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "USDCAD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.3794000148773193,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "ATOM-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 4.5298662185668945,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "BCH-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 595.4796142578125,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "LINK-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 22.34357452392578,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "XRP-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 3.117891550064087,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "DOT-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 4.020590305328369,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "BTC-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 119062.5703125,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GBPUSD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.3552889823913574,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "CHFJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 182.2550048828125,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "ADA-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 0.9589924216270447,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "XLM-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 0.42904016375541687,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURCHF=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 0.9415299892425537,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "BNB-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 845.3945922851562,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "DOGE-USD",
"time": "2025-08-15 10:30:00+00:00",
"price": 0.23236910998821259,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "NZDJPY=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 86.98799896240234,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURGBP=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 0.8621399998664856,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "SOL-USD",
"time": "2025-08-15 10:29:00+00:00",
"price": 195.72109985351562,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "EURAUD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.7960000038146973,
"signal": 0,
"period": "7d",
"interval": "1m",
"error": null
},
{
"ticker": "GBPCAD=X",
"time": "2025-08-15 10:31:00+00:00",
"price": 1.869420051574707,
"signal": 0,
"period": "7d",
"interval": "1m",

View File

@ -1,253 +1,221 @@
// app.js
// 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) + "%");
// ── utils ──────────────────────────────────────────────────────────────────────
const fmtNum = (x, d = 2) =>
x === null || x === undefined || Number.isNaN(x) ? "" : Number(x).toFixed(d);
// Potencjalne adresy backendu (API). Pierwszy to aktualny origin UI.
const apiCandidates = [
window.location.origin,
"http://127.0.0.1:8000",
"http://localhost:8000",
"http://172.27.20.120:8000", // z logów serwera
];
let API_BASE = null;
let warnedMixed = false;
function withTimeout(ms = 6000) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort("timeout"), ms);
return { signal: ctrl.signal, done: () => clearTimeout(id) };
async function getJSON(url) {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`${url}: ${r.status}`);
return r.json();
}
function makeUrl(path) {
return `${API_BASE}${path}${path.includes("?") ? "&" : "?"}_ts=${Date.now()}`;
}
// prosty wykres linii na canvas (bez bibliotek)
function drawLineChart(canvas, points) {
const ctx = canvas.getContext("2d");
const pad = 32;
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
function setBadge(id, text, ok = true) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.className = ok ? "badge ok" : "badge err";
}
function setBadgeTitle(id, title) {
const el = document.getElementById(id);
if (el) el.title = title || "";
}
function warnMixedContent(base) {
if (!warnedMixed && location.protocol === "https:" && base?.startsWith("http://")) {
warnedMixed = true;
console.warn(
"[api] UI działa przez HTTPS, a API przez HTTP — przeglądarka może blokować żądania (mixed content)."
);
alert(
"UI działa przez HTTPS, a API przez HTTP. Uruchom UI przez HTTP albo włącz HTTPS dla API — inaczej przeglądarka zablokuje żądania."
);
if (!points || points.length === 0) {
ctx.fillStyle = "#9aa3b2";
ctx.fillText("Brak danych", 10, 20);
return;
}
const n = points.length;
const ys = points.map(p => p.y);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const yLo = minY === maxY ? minY - 1 : minY;
const yHi = minY === maxY ? maxY + 1 : maxY;
const x0 = pad, y0 = h - pad, x1 = w - pad, y1 = pad;
const xScale = (i) => x0 + (i / (n - 1)) * (x1 - x0);
const yScale = (y) => y0 - ((y - yLo) / (yHi - yLo)) * (y0 - y1);
// osie
ctx.strokeStyle = "#242a36";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0, y0); ctx.lineTo(x1, y0);
ctx.moveTo(x0, y0); ctx.lineTo(x0, y1);
ctx.stroke();
// siatka
ctx.strokeStyle = "#1b2130";
[0.25, 0.5, 0.75].forEach(f => {
const yy = y0 - (y0 - y1) * f;
ctx.beginPath();
ctx.moveTo(x0, yy); ctx.lineTo(x1, yy);
ctx.stroke();
});
// podpisy min/max
ctx.fillStyle = "#9aa3b2";
ctx.font = "12px system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial";
ctx.fillText(fmt2(yHi), 6, y1 + 10);
ctx.fillText(fmt2(yLo), 6, y0 - 2);
// linia
ctx.strokeStyle = "#4da3ff";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xScale(0), yScale(ys[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xScale(i), yScale(ys[i]));
ctx.stroke();
// kropka na końcu
const lastX = xScale(n - 1), lastY = yScale(ys[n - 1]);
ctx.fillStyle = "#e7e9ee";
ctx.beginPath(); ctx.arc(lastX, lastY, 3, 0, Math.PI * 2); ctx.fill();
ctx.fillText(fmt2(ys[n - 1]), lastX + 6, lastY - 6);
}
// ── autodetekcja backendu ─────────────────────────────────────────────────────
async function pickBackend() {
for (const base of apiCandidates) {
try {
const t = withTimeout(2500);
const r = await fetch(`${base}/api/status?_ts=${Date.now()}`, {
cache: "no-store",
signal: t.signal,
});
t.done();
if (r.ok) {
API_BASE = base;
console.debug("[api] using", API_BASE);
warnMixedContent(API_BASE);
setBadgeTitle("loopState", `API: ${API_BASE}`);
return;
}
console.debug("[api] probe", base, "->", r.status);
} catch (e) {
// ignorujemy i próbujemy kolejny kandydat
console.debug("[api] probe fail", base, e?.message || e);
}
}
throw new Error("Nie znaleziono działającego backendu (API_BASE). Upewnij się, że server.py działa na porcie 8000.");
}
// ── API helpers ───────────────────────────────────────────────────────────────
async function apiGet(path) {
const url = makeUrl(path);
const t0 = performance.now();
const t = withTimeout(6000);
async function loadAll() {
try {
const r = await fetch(url, { cache: "no-store", signal: t.signal });
const t1 = performance.now();
console.debug("[api] GET", url, r.status, (t1 - t0).toFixed(1) + "ms");
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
} finally {
t.done();
}
}
const [snap, hist, pos, trades] = await Promise.all([
getJSON("/api/snapshot"),
getJSON("/api/history"),
getJSON("/api/positions"),
getJSON("/api/trades"),
]);
async function apiPost(path, body) {
const url = makeUrl(path);
const t0 = performance.now();
const t = withTimeout(6000);
try {
const r = await fetch(url, {
method: "POST",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
signal: t.signal,
// czas
document.getElementById("last-update").textContent =
"Ostatnia aktualizacja: " + (snap?.last_history?.time || "—");
// ===== ostatni wiersz historii =====
const last = Array.isArray(hist) && hist.length ? hist[hist.length - 1] : null;
const cashVal = Number(last?.cash ?? 0);
const totalVal = Number(
last?.total_value ??
(Number(last?.cash ?? 0) + Number(last?.positions_net ?? 0)) // fallback dla starszych logów
);
// KARTY
document.getElementById("cash").textContent = fmt2(cashVal);
document.getElementById("total-value").textContent = fmt2(totalVal);
// mapa ostatnich cen do liczenia zysku (unrealized)
const lastPrice = new Map();
(snap?.signals || []).forEach(s => {
const px = Number(s.price);
if (!isNaN(px)) lastPrice.set(s.ticker, px);
});
const t1 = performance.now();
console.debug("[api] POST", url, r.status, (t1 - t0).toFixed(1) + "ms");
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
} finally {
t.done();
}
}
// ── refreshers ────────────────────────────────────────────────────────────────
async function refreshStatus() {
try {
const s = await apiGet("/api/status");
setBadge("loopState", s.running ? "RUNNING" : "STOPPED", s.running);
setBadgeTitle("loopState", `API: ${API_BASE} | last_action=${s.last_action || ""}`);
// Zysk = suma niezrealizowanych PnL na otwartych pozycjach
let unrealPnL = 0;
(pos || []).forEach(p => {
const price = Number(lastPrice.get(p.ticker));
const entry = Number(p.entry);
const qty = Number(p.qty);
const side = Number(p.side);
if (isNaN(price) || isNaN(entry) || isNaN(qty) || isNaN(side)) return;
unrealPnL += side === 1 ? (price - entry) * qty : (entry - price) * qty;
});
const unrlEl = document.getElementById("unrealized");
unrlEl.textContent = fmt2(unrealPnL);
unrlEl.classList.remove("pnl-positive", "pnl-negative");
if (unrealPnL > 0) unrlEl.classList.add("pnl-positive");
else if (unrealPnL < 0) unrlEl.classList.add("pnl-negative");
const roundEl = document.getElementById("roundNo");
if (roundEl) roundEl.textContent = s.round ?? "";
// liczba otwartych pozycji
document.getElementById("open-pos").textContent =
Number(last?.open_positions ?? (pos?.length ?? 0));
const cashEl = document.getElementById("cash");
if (cashEl) cashEl.textContent = fmtNum(s.cash, 2);
// ===== WYKRES WARTOŚCI KONTA (TOTAL) =====
const totalCanvas = document.getElementById("totalChart");
const totalPoints = (hist || [])
.map((row, i) => {
const v = (row.total_value != null)
? Number(row.total_value)
: Number(row.cash ?? 0) + Number(row.positions_net ?? 0);
return { x: i, y: v };
})
.filter(p => !isNaN(p.y))
.slice(-500);
drawLineChart(totalCanvas, totalPoints);
// now-playing
const stageEl = document.getElementById("stage");
if (stageEl) stageEl.textContent = (s.stage || "idle").toUpperCase();
// ===== WYKRES GOTÓWKI (CASH) =====
const cashCanvas = document.getElementById("cashChart");
const cashPoints = (hist || [])
.map((row, i) => ({ x: i, y: Number(row.cash ?? NaN) }))
.filter(p => !isNaN(p.y))
.slice(-500);
drawLineChart(cashCanvas, cashPoints);
const tickerEl = document.getElementById("ticker");
if (tickerEl) tickerEl.textContent = s.current_ticker || "";
// ===== POZYCJE =====
const posBody = document.querySelector("#positions-table tbody");
posBody.innerHTML = "";
(pos || []).forEach((p) => {
const price = Number(lastPrice.get(p.ticker));
const entry = Number(p.entry);
const qty = Number(p.qty);
const side = Number(p.side);
const idx = s.current_index ?? 0;
const total = s.tickers_total ?? 0;
let upnl = NaN, upct = NaN;
if (!isNaN(price) && !isNaN(entry) && !isNaN(qty) && !isNaN(side)) {
upnl = side === 1 ? (price - entry) * qty : (entry - price) * qty;
const denom = Math.max(qty * entry, 1e-12);
upct = upnl / denom;
}
const pnlClass = upnl > 0 ? "pnl-positive" : (upnl < 0 ? "pnl-negative" : "");
const progressTextEl = document.getElementById("progressText");
if (progressTextEl) progressTextEl.textContent = `${Math.min(idx + 1, total)} / ${total}`;
const prog = document.getElementById("progress");
if (prog) {
prog.max = total || 1;
prog.value = Math.min(idx + 1, total) || 0;
}
const lastActionEl = document.getElementById("lastAction");
if (lastActionEl) lastActionEl.textContent = s.last_action || "";
} catch (e) {
console.error("status error:", e);
setBadge("loopState", "ERR", false);
setBadgeTitle("loopState", `API: ${API_BASE || "—"} | ${e?.message || e}`);
}
}
async function refreshPositions() {
try {
const { positions } = await apiGet("/api/positions");
const tbody = document.querySelector("#positions tbody");
if (!tbody) return;
tbody.innerHTML = "";
for (const p of positions) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${p.ticker}</td>
<td>${p.side}</td>
<td class="num">${fmtNum(p.size, 0)}</td>
<td class="num">${fmtNum(p.entry_price)}</td>
<td class="num">${fmtNum(p.last_price)}</td>
<td class="num">${fmtNum(p.pnl)}</td>
<td>${fmt6(qty)}</td>
<td>${fmt6(entry)}</td>
<td>${side === 1 ? "LONG" : "SHORT"}</td>
<td>${fmt6(price)}</td>
<td class="${pnlClass}">${fmt2(upnl)}</td>
<td class="${pnlClass}">${fmtPct(upct)}</td>
`;
tbody.appendChild(tr);
}
} catch (e) {
console.error("positions error:", e);
}
}
posBody.appendChild(tr);
});
async function refreshTrades() {
try {
const { trades } = await apiGet("/api/trades");
const tbody = document.querySelector("#trades tbody");
if (!tbody) return;
tbody.innerHTML = "";
trades.slice(-50).reverse().forEach((t) => {
// ===== SYGNAŁY =====
const sigBody = document.querySelector("#signals-table tbody");
sigBody.innerHTML = "";
const signals = (snap?.signals || []).slice().sort((a,b)=>a.ticker.localeCompare(b.ticker));
signals.forEach((s) => {
const sigTxt = s.signal === 1 ? "BUY" : (s.signal === -1 ? "SELL" : "HOLD");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${t.time ?? ""}</td>
<td>${t.ticker ?? ""}</td>
<td>${t.action ?? ""}</td>
<td class="num">${fmtNum(t.price)}</td>
<td class="num">${fmtNum(t.size, 0)}</td>
<td>${s.ticker}</td>
<td>${s.time}</td>
<td>${fmt6(s.price)}</td>
<td class="${sigTxt}">${sigTxt}</td>
<td>${s.interval}</td>
`;
tbody.appendChild(tr);
sigBody.appendChild(tr);
});
// ===== TRANSAKCJE =====
const tradesBody = document.querySelector("#trades-table tbody");
tradesBody.innerHTML = "";
(trades || []).slice(-50).reverse().forEach((t) => {
const pnlClass = t.pnl_abs > 0 ? "pnl-positive" : (t.pnl_abs < 0 ? "pnl-negative" : "");
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${t.time}</td>
<td class="${t.action}">${t.action}</td>
<td>${t.ticker}</td>
<td>${fmt6(t.price)}</td>
<td>${fmt6(t.qty)}</td>
<td class="${pnlClass}">${fmt2(t.pnl_abs)}</td>
<td class="${pnlClass}">${fmtPct(t.pnl_pct)}</td>
<td>${fmt2(t.cash_after)}</td>
`;
tradesBody.appendChild(tr);
});
} catch (e) {
console.error("trades error:", e);
console.error("loadAll error:", e);
document.getElementById("last-update").textContent = "Błąd ładowania danych";
}
}
async function refreshAll() {
await Promise.all([refreshStatus(), refreshPositions(), refreshTrades()]);
}
// ── auto refresh ──────────────────────────────────────────────────────────────
let timer = null;
let currentInterval = 2000; // domyślnie 2s (zgodne z Twoim selectem)
function startAutoRefresh() {
if (timer) return;
timer = setInterval(refreshAll, currentInterval);
console.debug("[ui] auto refresh started", currentInterval, "ms");
}
function stopAutoRefresh() {
if (!timer) return;
clearInterval(timer);
timer = null;
console.debug("[ui] auto refresh stopped");
}
// ── bootstrap ────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", async () => {
// Przyciski
document.getElementById("btnStart")?.addEventListener("click", async () => {
try { await apiPost("/api/start"); await refreshStatus(); } catch (e) { console.error(e); }
});
document.getElementById("btnStop")?.addEventListener("click", async () => {
try { await apiPost("/api/stop"); await refreshStatus(); } catch (e) { console.error(e); }
});
document.getElementById("btnTick")?.addEventListener("click", async () => {
try { await apiPost("/api/run-once"); await refreshAll(); } catch (e) { console.error(e); }
});
const sel = document.getElementById("refreshMs");
sel?.addEventListener("change", () => {
currentInterval = parseInt(sel.value, 10);
if (timer) { stopAutoRefresh(); startAutoRefresh(); }
});
document.getElementById("autoOn")?.addEventListener("click", startAutoRefresh);
document.getElementById("autoOff")?.addEventListener("click", stopAutoRefresh);
// Autodetekcja backendu przed pierwszym odświeżeniem
try {
await pickBackend();
await refreshAll();
startAutoRefresh();
} catch (e) {
console.error(e);
setBadge("loopState", "NO API", false);
setBadgeTitle("loopState", e?.message || String(e));
alert("UI nie może połączyć się z backendem (port 8000). Uruchom server.py lub zaktualizuj API_BASE w app.js.");
}
});
document.getElementById("refresh-btn").addEventListener("click", loadAll);
loadAll();
setInterval(loadAll, 1000);

View File

@ -1,41 +0,0 @@
:root{
--border:#e5e7eb;
--muted:#64748b;
}
*{ box-sizing:border-box; }
body{ font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin:0; color:#0f172a; background:#fff; }
.container{ max-width: 1100px; margin: 0 auto; padding: 16px; }
header{ display:flex; align-items:center; justify-content:space-between; margin-bottom: 16px; gap: 12px; }
h1{ margin:0; font-size: 22px; }
h2{ font-size:18px; margin: 18px 0 10px; }
h3{ font-size:16px; margin:0; }
#statusBar{ display:flex; align-items:center; gap: 12px; flex-wrap: wrap; }
#statusBar span{ font-size:14px; color:#0f172a; }
.btn{
appearance:none; border:1px solid var(--border); background:#0f172a; color:#fff;
padding:8px 12px; border-radius:10px; cursor:pointer; font-weight:600;
}
.btn:hover{ opacity:.9; }
.btn-secondary{ background:#475569; }
.grid{ display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 24px; }
.card{ background:#0f172a0d; border:1px solid var(--border); border-radius:12px; padding:12px; }
.kpi{ font-size: 24px; font-weight: 700; margin-top: 8px; }
.table-wrap{ overflow:auto; border:1px solid var(--border); border-radius:12px; }
table{ width:100%; border-collapse: collapse; font-size: 14px; }
th, td{ padding: 8px 10px; border-bottom: 1px solid #f1f5f9; white-space: nowrap; }
thead th{ position: sticky; top: 0; background: #fff; z-index: 1; }
tbody tr:hover{ background:#f8fafc; }
.pnl-pos{ color: #166534; font-weight:600; }
.pnl-neg{ color: #991b1b; font-weight:600; }
.badge{
padding:3px 8px; border-radius:999px; font-size:12px; border:1px solid var(--border);
display:inline-block;
}
.badge.long{ background:#ecfdf5; border-color:#bbf7d0; }
.badge.short{ background:#fef2f2; border-color:#fecaca; }
.empty{ text-align:center; color: var(--muted); padding: 14px; }

View File

@ -1,93 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import math
import numpy as np
import pandas as pd
from config import CFG
# zakładam, że masz te funkcje gdzieś u siebie:
# from indicators import ema, rsi, atr, macd, adx_val
from indicators import ema, rsi, atr, macd, adx_val
Signal = str # "BUY" | "SELL" | "NONE"
@dataclass
class Decision:
signal: Signal
sl: float | None
tp: float | None
rpu: float # risk per unit (odległość od SL)
def evaluate_signal(df: pd.DataFrame) -> Decision:
"""
Warunki (poluzowane / opcjonalne):
- (opcjonalnie) kierunek EMA200 (trend filter),
- ADX >= CFG.adx_min,
- ATR >= CFG.atr_min_frac_price * price,
- RSI nieprzeciążone (BUY < rsi_buy_max, SELL > rsi_sell_min),
- (opcjonalnie) MACD zgodny z kierunkiem.
Wyjście: SL = k_ATR, TP = RR * R (R = dystans do SL).
"""
if df is None or len(df) < CFG.history_min_bars:
return Decision("NONE", None, None, 0.0)
# Bezpieczne pobranie kolumn
cols = {c.lower(): c for c in df.columns}
C = pd.to_numeric(df[cols.get("close","Close")], errors="coerce").to_numpy(float)
H = pd.to_numeric(df[cols.get("high","High")], errors="coerce").to_numpy(float)
L = pd.to_numeric(df[cols.get("low","Low")], errors="coerce").to_numpy(float)
if len(C) == 0 or np.isnan(C[-1]):
return Decision("NONE", None, None, 0.0)
# wskaźniki
ema200 = ema(C, 200)
rsi14 = rsi(C, CFG.rsi_len)
atr14 = atr(H, L, C, 14)
macd_line, macd_sig, macd_hist = macd(C, 12, 26, 9)
adx14, pdi, mdi = adx_val(H, L, C, 14)
c = float(C[-1])
a = float(atr14[-1]) if math.isfinite(atr14[-1]) else 0.0
if not (math.isfinite(c) and math.isfinite(a)) or a <= 0:
return Decision("NONE", None, None, 0.0)
# filtry bazowe
adx_ok = float(adx14[-1]) >= CFG.adx_min
atr_ok = (a / max(1e-9, c)) >= CFG.atr_min_frac_price
# trend (opcjonalny)
trend_ok_buy = True if not CFG.require_trend else (c > float(ema200[-1]))
trend_ok_sell = True if not CFG.require_trend else (c < float(ema200[-1]))
# RSI „nieprzeciążone”
rsi_val = float(rsi14[-1])
rsi_ok_buy = rsi_val <= CFG.rsi_buy_max
rsi_ok_sell = rsi_val >= CFG.rsi_sell_min
# MACD (opcjonalny)
if CFG.use_macd_filter:
macd_buy = (macd_line[-1] > macd_sig[-1] and macd_hist[-1] > 0)
macd_sell = (macd_line[-1] < macd_sig[-1] and macd_hist[-1] < 0)
else:
macd_buy = macd_sell = True
signal: Signal = "NONE"
sl = tp = None
rpu = 0.0
if adx_ok and atr_ok:
if trend_ok_buy and rsi_ok_buy and macd_buy:
signal = "BUY"
sl = c - CFG.sl_atr_mult * a
risk = c - sl
tp = c + CFG.tp_rr * risk
rpu = max(1e-9, risk)
elif CFG.allow_short and trend_ok_sell and rsi_ok_sell and macd_sell:
signal = "SELL"
sl = c + CFG.sl_atr_mult * a
risk = sl - c
tp = c - CFG.tp_rr * risk
rpu = max(1e-9, risk)
return Decision(signal, sl, tp, rpu)

View File

@ -1,81 +1,114 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Trader Panel</title>
<link rel="stylesheet" href="/static/style.css"/>
<style>
body { font-family: system-ui, Arial, sans-serif; margin: 0; padding: 1rem; background: #fafafa; }
.toolbar { display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; margin-bottom:1rem; }
.badge { padding:.2rem .5rem; border-radius:.5rem; background:#ddd; font-weight:600; }
.badge.ok { background:#d1fae5; }
.badge.err { background:#fee2e2; }
.grid { display:grid; grid-template-columns: 1fr; gap: 1rem; }
@media(min-width: 900px){ .grid{ grid-template-columns: 1fr 1fr; } }
.card { border:1px solid #eee; border-radius:.75rem; padding:1rem; background:#fff; box-shadow:0 1px 4px rgba(0,0,0,.04); }
table { width:100%; border-collapse:collapse; }
th, td { border-bottom:1px solid #eee; padding:.4rem .5rem; }
td.num { text-align:right; font-variant-numeric: tabular-nums; }
button { cursor:pointer; }
.muted{ color:#666; }
.now { display:flex; gap:.5rem; align-items:center; }
progress { width: 220px; height: 10px; }
</style>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Trading Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
</head>
<body>
<div class="toolbar card">
<span>Loop:</span>
<span id="loopState" class="badge"></span>
<button id="btnStart">Start</button>
<button id="btnStop">Stop</button>
<button id="btnTick" title="Wykonaj jedną rundę (1 ticker)">Tick</button>
<header>
<h1>Trading Dashboard</h1>
<div class="meta">
<span id="last-update">Ostatnia aktualizacja: —</span>
<button id="refresh-btn">Odśwież teraz</button>
</div>
</header>
<span>| Auto-refresh:</span>
<button id="autoOn">On</button>
<button id="autoOff">Off</button>
<label class="muted">co <select id="refreshMs">
<option value="1000">1s</option>
<option value="2000" selected>2s</option>
<option value="5000">5s</option>
<option value="10000">10s</option>
</select></label>
<main>
<!-- KARTY -->
<section class="cards">
<div class="card">
<div class="card-title">Gotówka</div>
<div class="card-value" id="cash"></div>
</div>
<div class="card">
<div class="card-title">Wartość konta (total)</div>
<div class="card-value" id="total-value"></div>
</div>
<div class="card">
<div class="card-title">Zysk (otwarte pozycje)</div>
<div class="card-value" id="unrealized"></div>
</div>
<div class="card">
<div class="card-title">Otwarte pozycje</div>
<div class="card-value" id="open-pos"></div>
</div>
</section>
<span>| Runda: <b id="roundNo"></b></span>
<span>| Gotówka: <b id="cash"></b></span>
</div>
<!-- WYKRES WARTOŚCI KONTA (TOTAL) -->
<section>
<h2>Wartość konta (total) — wykres</h2>
<div class="chart-wrap">
<canvas id="totalChart" width="1200" height="300"></canvas>
</div>
</section>
<div class="card now">
<b>Teraz:</b>
<span id="stage"></span>
<span id="ticker"></span>
<span id="progressText" class="muted">0 / 0</span>
<progress id="progress" value="0" max="0"></progress>
<span class="muted">Ostatnia akcja:</span>
<span id="lastAction"></span>
</div>
<!-- WYKRES GOTÓWKI -->
<section>
<h2>Gotówka — wykres</h2>
<div class="chart-wrap">
<canvas id="cashChart" width="1200" height="300"></canvas>
</div>
</section>
<div class="grid">
<div class="card">
<h3>Pozycje</h3>
<table id="positions">
<!-- Otwarte pozycje -->
<section>
<h2>Otwarte pozycje</h2>
<table id="positions-table">
<thead>
<tr><th>Ticker</th><th>Strona</th><th>Ilość</th><th>Wejście</th><th>Ostatnia</th><th>PnL</th></tr>
<tr>
<th>Ticker</th>
<th>Qty</th>
<th>Entry</th>
<th>Side</th>
<th>Last price</th>
<th>Unreal. PnL</th>
<th>Unreal. PnL %</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="card">
<h3>Transakcje (ostatnie 50)</h3>
<table id="trades">
</section>
<!-- Ostatnie sygnały -->
<section>
<h2>Ostatnie sygnały</h2>
<table id="signals-table">
<thead>
<tr><th>Czas</th><th>Ticker</th><th>Akcja</th><th>Cena</th><th>Ilość</th></tr>
<tr>
<th>Ticker</th>
<th>Time</th>
<th>Price</th>
<th>Signal</th>
<th>Interval</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<script src="/static/app.js"></script>
<!-- Transakcje -->
<section>
<h2>Transakcje (ostatnie 50)</h2>
<table id="trades-table">
<thead>
<tr>
<th>Time</th>
<th>Action</th>
<th>Ticker</th>
<th>Price</th>
<th>Qty</th>
<th>PnL</th>
<th>PnL %</th>
<th>Cash po</th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
</main>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>

28
trade_log.json Normal file
View File

@ -0,0 +1,28 @@
[
{
"time": "2025-08-15 12:31:18",
"action": "SELL",
"ticker": "OP-USD",
"price": 0.7543854713439941,
"qty": 3976.7467878924763,
"side": -1,
"pnl_abs": 0.0,
"pnl_pct": 0.0,
"realized_pnl_cum": 0.0,
"cash_after": 10000.0,
"reason": ""
},
{
"time": "2025-08-15 12:32:18",
"action": "SELL",
"ticker": "OP-USD",
"price": 0.7553843259811401,
"qty": 3976.7467878924763,
"side": -1,
"pnl_abs": -3.972191969841845,
"pnl_pct": -0.0013240639899472903,
"realized_pnl_cum": -3.972191969841845,
"cash_after": 9996.027808030158,
"reason": "SIGNAL"
}
]

264
trader.py
View File

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