diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..7a183bf --- /dev/null +++ b/bot.py @@ -0,0 +1,3758 @@ +import pandas as pd +import numpy as np +import os +import pickle +from io import StringIO +import random +from collections import deque +from datetime import datetime, timedelta +import subprocess +import time +import argparse +import threading +import math +import glob +import MetaTrader5 as mt5 +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.distributions import Categorical +""" +from xlstm import ( + xLSTMBlockStack, + xLSTMBlockStackConfig, + mLSTMBlockConfig, + mLSTMLayerConfig, + FeedForwardConfig, +) +""" +from xlstm import ( + xLSTMBlockStack, + xLSTMBlockStackConfig, + sLSTMBlockConfig, + sLSTMLayerConfig, +) + +ACTIONS = ['hold', 'long', 'short'] + +def find_latest_dukascopy_csv(): + folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download") + + pattern = os.path.join(folder, "xauusd-m5-bid-*.csv") + + # print(pattern) + # print(glob.glob(pattern)) + # print(os.listdir(folder)) + + files = glob.glob(pattern) + + if not files: + raise FileNotFoundError(f"No CSV found matching {pattern}") + + return max(files, key=os.path.getmtime) + +def load_last_mb_xauusd(file_path=None, mb=8, delimiter=',', col_names=None): + if file_path is None: + file_path = find_latest_dukascopy_csv() + + print(f"Loading: {file_path}") + + file_size = os.path.getsize(file_path) + offset = max(file_size - mb * 1024 * 1024, 0) # start position + + with open(file_path, 'rb') as f: + # Seek to approximately 20 MB before EOF + f.seek(offset) + + # Read to the end of file from that offset + data = f.read().decode(errors='ignore') + + # If not at start of file, discard partial first line (incomplete) + if offset > 0: + data = data.split('\n', 1)[-1] + + """ + df = pd.read_csv(StringIO(data), delimiter=delimiter, header=None, engine='python') + + #if col_names: + print(df.head()) + print(df.columns) + print(df.shape) + + df.columns = ["Date", "Open", "High", "Low", "Close", "Volume"] + """ + + df = pd.read_csv( + StringIO(data), + delimiter=delimiter, + header=0, + engine="python" + ) + + """ + df.rename(columns={ + "timestamp": "Date", + "open": "Open", + "high": "High", + "low": "Low", + "close": "Close", + "volume": "Volume" + }, inplace=True) + """ + + df.columns = ["Date", "Open", "High", "Low", "Close", "Volume"] + + # Convert columns if needed, e.g.: + # df["Date"] = pd.to_datetime(df["Date"], format="%Y.%m.%d %H:%M", errors='coerce') + df["Date"] = pd.to_datetime( + df["Date"], + unit="ms", + utc=True + ) + # print(df.columns.tolist()) + # print(df.head()) + # for col in ["Open", "High", "Low", "Close", "Volume"]: + # df[col] = pd.to_numeric(df[col], errors='coerce') + # df['Date'] = pd.to_datetime(df['Date']) + df.set_index('Date', inplace=True) + df = df[['Open', 'High', 'Low', 'Close', 'Volume']].copy() + + # df = df.resample('15min').agg({ + # 'Open': 'first', + # 'High': 'max', + # 'Low': 'min', + # 'Close': 'last' + # }).dropna() + + print(f"Loaded: {file_path}") + + df = df.dropna() + + return df + +def ADX(df, period=14): + """ + Returns +DI, -DI and ADX using Wilder's smoothing. + Columns required: High, Low, Close + """ + high = df['High'] + low = df['Low'] + close = df['Close'] + + # --- directional movement ----------------------------------------- + # plus_dm = (high.diff() > low.diff()) * (high.diff()).clip(lower=0) + # minus_dm = (low.diff() > high.diff()) * (low.diff().abs()).clip(lower=0)̈́ + up = high.diff() + dn = -low.diff() + + plus_dm_array = np.where((up > dn) & (up > 0), up, 0.0) + minus_dm_array = np.where((dn > up) & (dn > 0), dn, 0.0) + + plus_dm = pd.Series(plus_dm_array, index=df.index) # ← wrap + minus_dm = pd.Series(minus_dm_array, index=df.index) # ← wrap + + # --- true range ---------------------------------------------------- + tr = pd.concat([ + (high - low), + (high - close.shift()).abs(), + (low - close.shift()).abs() + ], axis=1).max(axis=1) + + # --- Wilder smoothing --------------------------------------------- + atr = tr.ewm(alpha=1/period, adjust=False).mean() + plus_di = 100 * (plus_dm.ewm(alpha=1/period, adjust=False).mean() / atr) + minus_di = 100 * (minus_dm.ewm(alpha=1/period, adjust=False).mean() / atr) + + dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di) + adx = dx.ewm(alpha=1/period, adjust=False).mean() + adx = round(adx , 2) + plus_di = round(plus_di, 2) + minus_di = round(minus_di, 2) + + return adx, plus_di, minus_di + +def STOCH(df, period=14, smooth_d=3): + """ + Returns %K and %D stochastic oscillator. + + Columns required: + High, Low, Close + """ + + high = df['High'] + low = df['Low'] + close = df['Close'] + + # --- highest high / lowest low ------------------------------------ + lowest_low = low.rolling(window=period).min() + highest_high = high.rolling(window=period).max() + + # --- %K ------------------------------------------------------------ + k = 100 * ((close - lowest_low) / (highest_high - lowest_low)) + + # --- %D (smoothed %K) --------------------------------------------- + d = k.rolling(window=smooth_d).mean() + + k = round(k, 2) + d = round(d, 2) + + return k, d + +def EMA(df, period): + return df['Close'].ewm(span=period, adjust=False).mean().round(2) + +def EQH_EQL(df, threshold=10, lookback=72, min_distance=3): + + open_ = df["Open"].to_numpy() + close = df["Close"].to_numpy() + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + + body_high = np.maximum(open_, close) + body_low = np.minimum(open_, close) + + eqh = np.zeros(len(df), dtype=np.bool_) + eql = np.zeros(len(df), dtype=np.bool_) + + n = len(df) + + for i in range(n): + + start = max(0, i - lookback) + + for j in range(i - min_distance, start - 1, -1): + + # Equal High + if ( + abs(high[i] - high[j]) <= threshold or + abs(high[i] - body_high[j]) <= threshold or + abs(body_high[i] - high[j]) <= threshold or + abs(body_high[i] - body_high[j]) <= threshold + ): + eqh[i] = True + + # Equal Low + if ( + abs(low[i] - low[j]) <= threshold or + abs(low[i] - body_low[j]) <= threshold or + abs(body_low[i] - low[j]) <= threshold or + abs(body_low[i] - body_low[j]) <= threshold + ): + eql[i] = True + + if eqh[i] and eql[i]: + break + + return eqh, eql + +def EQHEQLRetest(df, threshold=20, lookback=12): + + open_ = df["Open"].to_numpy() + close = df["Close"].to_numpy() + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + + body_high = np.maximum(open_, close) + body_low = np.minimum(open_, close) + + eqh = df["eqh"].to_numpy() + eql = df["eql"].to_numpy() + + eqh_retest = np.zeros(len(df), dtype=np.bool_) + eql_retest = np.zeros(len(df), dtype=np.bool_) + + n = len(df) + + for i in range(n): + + start = max(0, i - lookback) + + # Previous EQH + for j in range(i - 1, start - 1, -1): + + if not eqh[j]: + continue + + level = max(high[j], body_high[j]) + + if ( + abs(high[i] - level) <= threshold or + abs(body_high[i] - level) <= threshold + # and df["bullish"].iloc[i] + ): + eqh_retest[i] = True + break + + # Previous EQL + for j in range(i - 1, start - 1, -1): + + if not eql[j]: + continue + + level = min(low[j], body_low[j]) + + if ( + ( + abs(low[i] - level) <= threshold or + abs(body_low[i] - level) <= threshold + ) + and df["bearish"].iloc[i] + ): + eql_retest[i] = True + break + + return eqh_retest, eql_retest +def Indecision(df, threshold=0.2): + body = (df["Close"] - df["Open"]).abs() + candle_range = (df["High"] - df["Low"]).replace(0, 1e-9) + + return (body / candle_range < threshold).astype(int) + +def RejectionBlocks(df, wick_ratio=2.0): + body = (df["Close"] - df["Open"]).abs() + + upper = df["High"] - df[["Open", "Close"]].max(axis=1) + lower = df[["Open", "Close"]].min(axis=1) - df["Low"] + + bullish_rb = ( + (lower > body * wick_ratio) & + (lower > upper) + ).astype(int) + + bearish_rb = ( + (upper > body * wick_ratio) & + (upper > lower) + ).astype(int) + + return bullish_rb, bearish_rb + +def BullishOB(df, multiplier=1.5): + body = (df["Close"] - df["Open"]).abs() + next_body = body.shift(-1) + + bearish = df["Close"] < df["Open"] + next_bullish = df["Close"].shift(-1) > df["Open"].shift(-1) + + return ( + bearish & + next_bullish & + (body > df["range_ma"]) & + (next_body >= body * multiplier) + ).astype(int) + +def BearishOB(df, multiplier=1.5): + body = (df["Close"] - df["Open"]).abs() + next_body = body.shift(-1) + + bullish = df["Close"] > df["Open"] + next_bearish = df["Close"].shift(-1) < df["Open"].shift(-1) + + return ( + bullish & + next_bearish & + (body > df["range_ma"]) & + (next_body >= body * multiplier) + ).astype(int) + +def BullishFVG(df): + return (df["Low"].shift(-1) > df["High"].shift(1)).astype(int) + +def BearishFVG(df): + return (df["High"].shift(-1) < df["Low"].shift(1)).astype(int) + +def BullishIFVG(df): + return ( + (df["bearish_fvg"].shift(1) == 1) & + (df["Close"] > df["Low"].shift(2)) + ).astype(int) + +def BearishIFVG(df): + return ( + (df["bullish_fvg"].shift(1) == 1) & + (df["Close"] < df["High"].shift(2)) + ).astype(int) + +def BullishMB(df, multiplier=1.0): + body = (df["Close"] - df["Open"]).abs() + + bearish = df["Close"] < df["Open"] + next_bullish = df["Close"].shift(-1) > df["Open"].shift(-1) + displacement = body.shift(-1) >= body * multiplier + + ob = bearish & next_bullish & displacement + + ob_high = df["High"].where(ob).ffill() + ob_low = df["Low"].where(ob).ffill() + + return ((df["Low"] <= ob_high) & + (df["High"] >= ob_low)).astype(int) + +def BearishMB(df, multiplier=1.0): + body = (df["Close"] - df["Open"]).abs() + + bullish = df["Close"] > df["Open"] + next_bearish = df["Close"].shift(-1) < df["Open"].shift(-1) + displacement = body.shift(-1) >= body * multiplier + + ob = bullish & next_bearish & displacement + + ob_high = df["High"].where(ob).ffill() + ob_low = df["Low"].where(ob).ffill() + + return ((df["Low"] <= ob_high) & + (df["High"] >= ob_low)).astype(int) + +def Swings(df, left=2, right=2): + + body_high = np.maximum(df["Open"].to_numpy(), df["Close"].to_numpy()) + body_low = np.minimum(df["Open"].to_numpy(), df["Close"].to_numpy()) + + swing_high = [0] * len(df) + swing_low = [0] * len(df) + + for i in range(left, len(df) - right): + + # Swing High (body only) + if ( + body_high[i] > body_high[i-1] and + body_high[i] > body_high[i-2] and + body_high[i] > body_high[i+1] and + body_high[i] > body_high[i+2] + ): + swing_high[i] = 1 + + # Swing Low (body only) + if ( + body_low[i] < body_low[i-1] and + body_low[i] < body_low[i-2] and + body_low[i] < body_low[i+1] and + body_low[i] < body_low[i+2] + ): + swing_low[i] = 1 + + return swing_high, swing_low + +def MSS(df): + + bullish = pd.Series(0, index=df.index) + bearish = pd.Series(0, index=df.index) + + last_swing_high = None + last_swing_low = None + + for i in range(len(df)): + + if df["swing_high"].iloc[i]: + last_swing_high = df["High"].iloc[i] + + if df["swing_low"].iloc[i]: + last_swing_low = df["Low"].iloc[i] + + if ( + last_swing_high is not None + and df["Close"].iloc[i] > last_swing_high + ): + bullish.iloc[i] = 1 + last_swing_high = None + + if ( + last_swing_low is not None + and df["Close"].iloc[i] < last_swing_low + ): + bearish.iloc[i] = 1 + last_swing_low = None + + return bullish, bearish + +def MSS_Retest(df, lookback=72, threshold=3): + + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + + bullish_mss = df["bullish_mss"].to_numpy() + bearish_mss = df["bearish_mss"].to_numpy() + + bullish_retest = np.zeros(len(df), dtype=np.int8) + bearish_retest = np.zeros(len(df), dtype=np.int8) + + last_bull_level = None + last_bear_level = None + last_bull_index = -1 + last_bear_index = -1 + + for i in range(len(df)): + + # Remember bullish MSS level + if bullish_mss[i]: + last_bull_level = high[i] + last_bull_index = i + + # Remember bearish MSS level + if bearish_mss[i]: + last_bear_level = low[i] + last_bear_index = i + + # Bullish retest + if ( + last_bull_level is not None + and i - last_bull_index <= lookback + and low[i] <= last_bull_level + threshold + and high[i] >= last_bull_level - threshold + ): + bullish_retest[i] = 1 + last_bull_level = None + + # Bearish retest + if ( + last_bear_level is not None + and i - last_bear_index <= lookback + and high[i] >= last_bear_level - threshold + and low[i] <= last_bear_level + threshold + ): + bearish_retest[i] = 1 + last_bear_level = None + + return bullish_retest, bearish_retest + +def BoS(df): + + bullish_bos = np.zeros(len(df), dtype=np.int8) + bearish_bos = np.zeros(len(df), dtype=np.int8) + + last_high = None + last_low = None + + trend = None + + for i in range(len(df)): + + # New swing high + if df["swing_high"].iat[i]: + + high = df["High"].iat[i] + + if last_high is not None: + + if high > last_high: + # Higher High + if trend == "bull": + bullish_bos[i] = 1 + trend = "bull" + + elif high < last_high: + # Lower High + trend = "bear" + + last_high = high + + # New swing low + if df["swing_low"].iat[i]: + + low = df["Low"].iat[i] + + if last_low is not None: + + if low < last_low: + # Lower Low + if trend == "bear": + bearish_bos[i] = 1 + trend = "bear" + + elif low > last_low: + # Higher Low + trend = "bull" + + last_low = low + + return bullish_bos, bearish_bos + +def Trend(df): + + bullish_trend = np.zeros(len(df), dtype=np.int8) + bearish_trend = np.zeros(len(df), dtype=np.int8) + + bull_count = 0 + bear_count = 0 + + trend = 0 + # 0 = none + # 1 = bullish + # -1 = bearish + + for i in range(len(df)): + + if df["bullish_bos"].iat[i]: + bull_count += 1 + bear_count = 0 + + if bull_count >= 2: + trend = 1 + + elif df["bearish_bos"].iat[i]: + bear_count += 1 + bull_count = 0 + + if bear_count >= 2: + trend = -1 + + if trend == 1: + bullish_trend[i] = 1 + elif trend == -1: + bearish_trend[i] = 1 + + return bullish_trend, bearish_trend + +def CHoCH(df, lookback=72): + + bullish_choch = np.zeros(len(df), dtype=np.int8) + bearish_choch = np.zeros(len(df), dtype=np.int8) + + for i in range(lookback, len(df)): + + start = max(0, i - lookback) + + # ---------- Bullish trend -> look for bearish CHoCH ---------- + if df["bullish_trend"].iat[i]: + + last_hl = None + + for j in range(i - 1, start - 1, -1): + if df["swing_low"].iat[j]: + last_hl = df["Low"].iat[j] + break + + if last_hl is not None and df["Close"].iat[i] < last_hl: + bearish_choch[i] = 1 + + # ---------- Bearish trend -> look for bullish CHoCH ---------- + elif df["bearish_trend"].iat[i]: + + last_lh = None + + for j in range(i - 1, start - 1, -1): + if df["swing_high"].iat[j]: + last_lh = df["High"].iat[j] + break + + if last_lh is not None and df["Close"].iat[i] > last_lh: + bullish_choch[i] = 1 + + return bullish_choch, bearish_choch + +def BullishBB(df, lookback=72): + + bb = np.zeros(len(df), dtype=np.int8) + + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + bearish_ob = df["bearish_ob"].to_numpy() + + n = len(df) + + for i in range(n): + + start = max(0, i - lookback) + + for j in range(i - 1, start - 1, -1): + + if bearish_ob[j] != 1: + continue + + ob_high = high[j] + ob_low = low[j] + + # Highest high and lowest low since the OB formed + highest = np.max(high[j+1:i+1]) + lowest = np.min(low[j+1:i+1]) + + if highest >= ob_high and lowest <= ob_low: + bb[i] = 1 + break + + return bb + +def BearishBB(df, lookback=72): + + bb = np.zeros(len(df), dtype=np.int8) + + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + bullish_ob = df["bullish_ob"].to_numpy() + + n = len(df) + + for i in range(n): + + start = max(0, i - lookback) + + for j in range(i - 1, start - 1, -1): + + if bullish_ob[j] != 1: + continue + + ob_high = high[j] + ob_low = low[j] + + # Highest high and lowest low since the OB formed + highest = np.max(high[j + 1:i + 1]) + lowest = np.min(low[j + 1:i + 1]) + + # Order block has been fully mitigated + if highest >= ob_high and lowest <= ob_low: + bb[i] = 1 + break + + return bb + +def ExhaustionCandle(df): + open_ = df["Open"].to_numpy() + close = df["Close"].to_numpy() + + body = np.abs(close - open_) + + exhaustion = np.zeros(len(df), dtype=np.bool_) + + exhaustion[1:] = body[1:] <= body[:-1] * 0.10 + + return exhaustion + +def OBMitigation(df, threshold=30, lookback=72): + + low = df["Low"].to_numpy() + high = df["High"].to_numpy() + + bullish_ob = df["bullish_ob"].to_numpy() + bearish_ob = df["bearish_ob"].to_numpy() + + bull = np.zeros(len(df), dtype=np.bool_) + bear = np.zeros(len(df), dtype=np.bool_) + + n = len(df) + + for i in range(n): + + start = max(0, i - lookback) + + for j in range(i - 1, start - 1, -1): + + if not np.isnan(bullish_ob[j]): + + if abs(low[i] - bullish_ob[j]) <= threshold: + bull[i] = True + break + + for j in range(i - 1, start - 1, -1): + + if not np.isnan(bearish_ob[j]): + + if abs(high[i] - bearish_ob[j]) <= threshold: + bear[i] = True + break + + return bull, bear + +def TrendLines(df): + + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + close = df["Close"].to_numpy() + + swing_high = df["swing_high"].to_numpy() + swing_low = df["swing_low"].to_numpy() + + above_support = np.zeros(len(df), dtype=np.bool_) + below_resistance = np.zeros(len(df), dtype=np.bool_) + + n = len(df) + + # ===================================================== + # Support + # ===================================================== + + lows = np.where(swing_low)[0] + + for k in range(1, len(lows)): + + i1 = lows[k - 1] + i2 = lows[k] + + # Only higher lows + if low[i2] <= low[i1]: + continue + + slope = (low[i2] - low[i1]) / (i2 - i1) + + end = lows[k + 1] if k + 1 < len(lows) else n + + for j in range(i2 + 1, end): + + trend_price = low[i2] + slope * (j - i2) + + if close[j] >= trend_price: + above_support[j] = True + + # ===================================================== + # Resistance + # ===================================================== + + highs = np.where(swing_high)[0] + + for k in range(1, len(highs)): + + i1 = highs[k - 1] + i2 = highs[k] + + # Only lower highs + if high[i2] >= high[i1]: + continue + + slope = (high[i2] - high[i1]) / (i2 - i1) + + end = highs[k + 1] if k + 1 < len(highs) else n + + for j in range(i2 + 1, end): + + trend_price = high[i2] + slope * (j - i2) + + if close[j] <= trend_price: + below_resistance[j] = True + + return above_support, below_resistance + +def Fibonacci(df, min_swing=7.5, tolerance=0.05): + + high = df["High"].to_numpy() + low = df["Low"].to_numpy() + open_ = df["Open"].to_numpy() + close = df["Close"].to_numpy() + + body_high = np.maximum(open_, close) + body_low = np.minimum(open_, close) + + swing_high = df["swing_high"].to_numpy() + swing_low = df["swing_low"].to_numpy() + + bullish_fib382 = np.zeros(len(df), dtype=np.bool_) + bullish_fib500 = np.zeros(len(df), dtype=np.bool_) + bullish_fib618 = np.zeros(len(df), dtype=np.bool_) + bullish_fib786 = np.zeros(len(df), dtype=np.bool_) + + bearish_fib382 = np.zeros(len(df), dtype=np.bool_) + bearish_fib500 = np.zeros(len(df), dtype=np.bool_) + bearish_fib618 = np.zeros(len(df), dtype=np.bool_) + bearish_fib786 = np.zeros(len(df), dtype=np.bool_) + + last_type = None + last_index = None + + n = len(df) + + for i in range(n): + + # ========================================================== + # Bullish impulse (Swing Low -> Swing High) + # ========================================================== + + if swing_high[i] and last_type == "low": + + swing = high[i] - low[last_index] + + if swing >= min_swing: + + fib382 = high[i] - swing * 0.382 + fib500 = high[i] - swing * 0.500 + fib618 = high[i] - swing * 0.618 + fib786 = high[i] - swing * 0.786 + threshold = swing * tolerance + + j = i + + while ( + j < n and + not swing_high[j] and + not swing_low[j] + ): + + bullish_fib382[j] = ( + body_low[j] <= fib382 + threshold and + body_high[j] >= fib382 - threshold + ) + + bullish_fib500[j] = ( + body_low[j] <= fib500 + threshold and + body_high[j] >= fib500 - threshold + ) + + bullish_fib618[j] = ( + body_low[j] <= fib618 + threshold and + body_high[j] >= fib618 - threshold + ) + + bullish_fib786[j] = ( + body_low[j] <= fib786 + threshold and + body_high[j] >= fib786 - threshold + ) + + j += 1 + + last_type = "high" + last_index = i + + # ========================================================== + # Bearish impulse (Swing High -> Swing Low) + # ========================================================== + + elif swing_low[i] and last_type == "high": + + swing = high[last_index] - low[i] + + if swing >= min_swing: + + fib382 = low[i] + swing * 0.382 + fib500 = low[i] + swing * 0.500 + fib618 = low[i] + swing * 0.618 + fib786 = low[i] + swing * 0.786 + + j = i + + while ( + j < n and + not swing_high[j] and + not swing_low[j] + ): + + bearish_fib382[j] = ( + body_low[j] <= fib382 + threshold and + body_high[j] >= fib382 - threshold + ) + + bearish_fib500[j] = ( + body_low[j] <= fib500 + threshold and + body_high[j] >= fib500 - threshold + ) + + bearish_fib618[j] = ( + body_low[j] <= fib618 + threshold and + body_high[j] >= fib618 - threshold + ) + + bearish_fib786[j] = ( + body_low[j] <= fib786 + threshold and + body_high[j] >= fib786 - threshold + ) + + j += 1 + + last_type = "low" + last_index = i + + elif swing_low[i]: + last_type = "low" + last_index = i + + elif swing_high[i]: + last_type = "high" + last_index = i + + return ( + bullish_fib382, + bullish_fib500, + bullish_fib618, + bullish_fib786, + bearish_fib382, + bearish_fib500, + bearish_fib618, + bearish_fib786, + ) + +def FVGRetracement(df, tolerance=0.05): + + open_ = df["Open"].to_numpy() + close = df["Close"].to_numpy() + + body_high = np.maximum(open_, close) + body_low = np.minimum(open_, close) + body_mid = (body_high + body_low) / 2 + + bull_fvg = df["bullish_fvg"].to_numpy() + bear_fvg = df["bearish_fvg"].to_numpy() + + bull_ret = np.zeros(len(df), dtype=np.bool_) + bear_ret = np.zeros(len(df), dtype=np.bool_) + + n = len(df) + + # ------------------------- + # Bullish FVG + # ------------------------- + for i in range(1, n - 1): + + if not bull_fvg[i]: + continue + + # Fib: 0 = first candle low, 1 = third candle high + swing_low = df["Low"].iat[i - 1] + swing_high = df["High"].iat[i + 1] + + height = swing_high - swing_low + if height <= 0: + continue + + fib382 = swing_high - 0.382 * height + fib500 = swing_high - 0.500 * height + + tol = height * tolerance + + for j in range(i + 2, n): + + # Stop when bullish continuation resumes + if close[j] >= open_[j]: + break + + if ( + abs(body_mid[j] - fib382) <= tol or + abs(body_mid[j] - fib500) <= tol + ): + bull_ret[j] = True + break + + # ------------------------- + # Bearish FVG + # ------------------------- + for i in range(1, n - 1): + + if not bear_fvg[i]: + continue + + # Fib: 0 = first candle high, 1 = third candle low + swing_high = df["High"].iat[i - 1] + swing_low = df["Low"].iat[i + 1] + + height = swing_high - swing_low + if height <= 0: + continue + + fib382 = swing_low + 0.382 * height + fib500 = swing_low + 0.500 * height + + tol = height * tolerance + + for j in range(i + 2, n): + + # Stop when bearish continuation resumes + if close[j] <= open_[j]: + break + + if ( + abs(body_mid[j] - fib382) <= tol or + abs(body_mid[j] - fib500) <= tol + ): + bear_ret[j] = True + break + + return bull_ret, bear_ret + +def AsiaHighDistance(df): + # Asia session: 01:00-08:59 (your timezone) + asia = (df.index.hour >= 1) | (df.index.hour <= 9) + + trade_day = (df.index - pd.Timedelta(hours=24)).date + + asia_high = ( + df["High"] + .where(asia) + .groupby(trade_day) + .transform("max") + .ffill() + ) + + return asia_high - df["Close"] + +def AsiaLowDistance(df): + asia = (df.index.hour >= 1) | (df.index.hour <= 9) + + trade_day = (df.index - pd.Timedelta(hours=24)).date + + asia_low = ( + df["Low"] + .where(asia) + .groupby(trade_day) + .transform("min") + .ffill() + ) + + return df["Close"] - asia_low + +def PDHDistance(df): + day = df.index.date + + daily_high = ( + df["High"] + .groupby(day) + .transform("max") + ) + + pdh = ( + daily_high + .groupby(day) + .first() + .shift(1) + .reindex(day) + .to_numpy() + ) + + return pdh - df["Close"] + +def PDLDistance(df): + day = df.index.date + + daily_low = ( + df["Low"] + .groupby(day) + .transform("min") + ) + + pdl = ( + daily_low + .groupby(day) + .first() + .shift(1) + .reindex(day) + .to_numpy() + ) + + return df["Close"] - pdl + +def PDPOCDistance(df, bins=50): + day = df.index.date + poc = np.full(len(df), np.nan) + + unique_days = np.unique(day) + + prev_poc = np.nan + + for d in unique_days: + # Assign previous day's POC to today's candles + mask = day == d + poc[mask] = prev_poc + + # Compute today's POC for use tomorrow + today = df.loc[mask] + + if len(today) > 1: + prices = ((today["High"] + today["Low"] + today["Close"]) / 3).values + volumes = today["Volume"].values + + hist, edges = np.histogram( + prices, + bins=bins, + weights=volumes + ) + + idx = np.argmax(hist) + prev_poc = (edges[idx] + edges[idx + 1]) / 2 + + return df["Close"] - poc + +def VWAP(df): + + # print(type(df.index)) + # print(df.index.dtype) + # print(df.index[:5]) + + # -------------------------------------------------- + # Select volume column + # -------------------------------------------------- + if "Volume" in df.columns: + volume = df["Volume"] + elif "tick_volume" in df.columns: + volume = df["tick_volume"] + elif "real_volume" in df.columns: + volume = df["real_volume"] + else: + raise ValueError("No volume column found.") + + # -------------------------------------------------- + # VWAP + # -------------------------------------------------- + typical_price = ( + df["High"] + + df["Low"] + + df["Close"] + ) / 3 + + # print(df.columns) + # df["Date"] = ( + # pd.to_datetime(df["Date"], unit="ms", utc=True) + # .dt.tz_convert("Europe/Denmark") + # .dt.tz_localize(None) + # ) + # index = df.index.tz_convert("Europe/Copenhagen") + # print(f"vwap time: {df.index[-1]}") + + session = df.index.normalize() + # session = (df.index - pd.Timedelta(hours=1)).normalize() + + cum_tpv = (typical_price * volume).groupby(session).cumsum() + cum_volume = volume.groupby(session).cumsum() + + vwap = round(cum_tpv / cum_volume, 2) + + # -------------------------------------------------- + # Session VWAP Standard Deviation + # -------------------------------------------------- + + # Squared distance from VWAP + sq_diff = ((typical_price - vwap) ** 2) * volume + + # Cumulative weighted variance + cum_sq_diff = sq_diff.groupby(session).cumsum() + + variance = cum_sq_diff / cum_volume + stddev = variance.pow(0.5) + + upper = round(vwap + stddev, 2) + lower = round(vwap - stddev, 2) + + # -------------------------------------------------- + # Derived features + # -------------------------------------------------- + # dist = df["Close"] - vwap + + above = (df["Close"] > vwap).astype(int) + below = (df["Close"] < vwap).astype(int) + + above_upper = (df["Close"] > upper).astype(int) + below_lower = (df["Close"] < lower).astype(int) + + slope = vwap.diff() + + return ( + vwap, + upper, + lower, + # dist, + above, + below, + above_upper, + below_lower, + slope + ) + +def GetRange(df): + """ + Returns normalized candle range as a percentage of price. + """ + return (df["High"] - df["Low"]) * 10 + +def RangeMA(df, period=14): + + # ----------------------------------------- + # Select volume column + # ----------------------------------------- + # if "volume" in df.columns: + # volume = df["Volume"] + # elif "tick_volume" in df.columns: + # volume = df["tick_volume"] + # elif "Volume" in df.columns: + # volume = df["Volume"] + # else: + # raise ValueError("No volume column found.") + + volume = df["range"] + + # print(type(volume)) + # print(volume.shape) + # print(df.columns.tolist()) + + return round(volume.rolling(period).mean(), 2) + +def BullishBearish(df): + """ + Returns: + bullish (np.ndarray[bool]) + bearish (np.ndarray[bool]) + """ + + open = df["Open"].to_numpy() + close = df["Close"].to_numpy() + + bullish = close > open + bearish = close < open + + return bullish, bearish + +def add_irl_erl(df, swing_window=5): + """ + Adds: + range_high + range_low + erl_high + erl_low + irl + + Assumes df contains: + High + Low + Close + """ + + # df = df.copy() + + # ---------------------------- + # Current dealing range + # ---------------------------- + df["range_high"] = np.where(df["swing_high"], df["High"], np.nan) + df["range_low"] = np.where(df["swing_low"], df["Low"], np.nan) + + df["range_high"] = df["range_high"].ffill() + df["range_low"] = df["range_low"].ffill() + + # ---------------------------- + # External liquidity + # ---------------------------- + df["erl_high"] = df["High"] > df["range_high"] + df["erl_low"] = df["Low"] < df["range_low"] + + # ---------------------------- + # Internal liquidity + # ---------------------------- + df["irl"] = ( + (df["High"] <= df["range_high"]) + & (df["Low"] >= df["range_low"]) + ) + + return df + +def GetKillzone(df, asia=(1, 4), london=(10, 13), newyork=(15, 18)): + """ + Returns: + 0 = None + 1 = Asia + 2 = London + 3 = New York + + Expects df.index to be a DatetimeIndex. + """ + + hours = df.index.hour + + session = np.zeros(len(df), dtype=np.int8) + + session[(hours >= asia[0]) & (hours < asia[1])] = 1 + session[(hours >= london[0]) & (hours < london[1])] = 2 + session[(hours >= newyork[0]) & (hours < newyork[1])] = 3 + + return session + +def BuyScore(df): + score = ( + # Trend filter + (df["EMA7"] > df["EMA21"]).astype(int) + + (df["EMA721_DIFF"] > 0).astype(int) + + (df["EMA7_Slope"] > 0).astype(int) + + (df["EMA2150_DIFF"] > 0).astype(int) + + (df["EMA21_Slope"] > 0).astype(int) + + (df["EMA50"] > df["EMA200"]).astype(int) + + (df["EMA50200_DIFF"] > 0).astype(int) + + (df["EMA50_Slope"] > 0).astype(int) + + (df["EMA200_Slope"] > 0).astype(int) + + (df["+di"] > df["-di"]).astype(int) + + (df["adx"] > 20).astype(int) + + (df["k"] > df["k_smooth"]).astype(int) + + + # OB mitigation + df["bullish_ob_mitigation"] + + + # Breaker Block + df["bullish_bb"] + + + # MSS + (df["bullish_mss"] & + (df["bullish_trend"] == 0) + ).astype(int) + + + df["bullish_choch"] + + + # MSS Retest + df["bullish_mss_retest"] + + + # FVG + MB/RB confluence + ( + df["bullish_fvg_retracement"] & + (df["bullish_mb"] | df["bullish_rb"]) + ).astype(int) + + + # Liquidity + ((df["asia_high_dist"] >= 0) & (df["asia_high_dist"] <= 3)).astype(int) + + ((df["asia_low_dist"] >= -3) & (df["asia_low_dist"] <= 0)).astype(int) + + ((df["pdh_dist"] >= 0) & (df["pdh_dist"] <= 3)).astype(int) + + ((df["pdl_dist"] >= -3) & (df["pdl_dist"] <= 0)).astype(int) + + + # Equal lows + df["eql"] + + + # Fib + df["bullish_fib382"] + + df["bullish_fib500"] + + df["bullish_fib618"] + ) + + score *= (df["killzone"].isin([1, 2, 3])).astype(int) + score *= (df["adx"] >= 20).astype(int) + + return score + +def SellScore(df): + score = ( + # Trend filter + (df["EMA7"] < df["EMA21"]).astype(int) + + (df["EMA721_DIFF"] < 0).astype(int) + + (df["EMA7_Slope"] < 0).astype(int) + + (df["EMA21"] < df["EMA50"]).astype(int) + + (df["EMA2150_DIFF"] < 0).astype(int) + + (df["EMA21_Slope"] < 0).astype(int) + + (df["EMA50"] < df["EMA200"]).astype(int) + + (df["EMA50200_DIFF"] < 0).astype(int) + + (df["EMA50_Slope"] < 0).astype(int) + + (df["EMA200_Slope"] < 0).astype(int) + + (df["-di"] > df["+di"]).astype(int) + + (df["adx"] > 20).astype(int) + + (df["k"] < df["k_smooth"]).astype(int) + + + # OB mitigation + df["bearish_ob_mitigation"] + + + # Breaker Block + df["bearish_bb"] + + + # MSS + (df["bearish_mss"] & + (df["bearish_trend"] == 0) + ).astype(int) + + + # MSS Retest + df["bearish_mss_retest"] + + + df["bearish_choch"] + + + # FVG + MB/RB confluence + ( + df["bearish_fvg_retracement"] & + (df["bearish_mb"] | df["bearish_rb"]) + ).astype(int) + + + # Liquidity + ((df["asia_high_dist"] >= -3) & (df["asia_high_dist"] <= 0)).astype(int) + + ((df["asia_low_dist"] >= 0) & (df["asia_low_dist"] <= 3)).astype(int) + + ((df["pdh_dist"] >= -3) & (df["pdh_dist"] <= 0)).astype(int) + + ((df["pdl_dist"] >= 0) & (df["pdl_dist"] <= 3)).astype(int) + + + # Equal highs + df["eqh"] + + + # Fib + df["bearish_fib382"] + + df["bearish_fib500"] + + df["bearish_fib618"] + ) + + score *= (df["killzone"].isin([1, 2, 3])).astype(int) + score *= (df["adx"] >= 20).astype(int) + + return score + +def add_indicators(df): + # print("Loading indicators") + df['adx'], df['+di'], df['-di'] = ADX(df) + + df['k'], df['k_smooth'] = STOCH(df) + + df['EMA7'] = EMA(df, 7) + # df['EMA1'] = EMA(df, 1) + # df['EMA1_Slope'] = df['EMA1'].diff() + df['EMA7_Slope'] = df['EMA7'].diff() + df['EMA21'] = EMA(df, 21) + df['EMA21_Slope'] = df['EMA21'].diff() + df['EMA721_DIFF'] = df['EMA7'] - df['EMA21'] + df['EMA50'] = EMA(df, 50) + df['EMA2150_DIFF'] = df['EMA21'] - df['EMA50'] + df['EMA50_Slope'] = df['EMA50'].diff() + df['EMA200'] = EMA(df, 200) + df['EMA50200_DIFF'] = df['EMA50'] - df['EMA200'] + df['EMA200_Slope'] = df['EMA200'].diff() + # df['EMA1'] = EMA(df, 1) + # df['EMA71_DIFF'] = df['EMA1'] - df['EMA7'] + + df["vwap"], df["vwap_upper"], df["vwap_lower"], df["above_vwap"], df["below_vwap"], df["vwap_above_upper"], df["vwap_below_lower"], df["vwap_slope"] = VWAP(df) + + df["range"] = GetRange(df) + df["range_ma"] = RangeMA(df) + + df["indecision"] = Indecision(df) + + df["bullish_ob"] = BullishOB(df) + df["bearish_ob"] = BearishOB(df) + df["bullish_ob_mitigation"], df["bearish_ob_mitigation"] = OBMitigation(df) + + df["bullish_fvg"] = BullishFVG(df) + df["bearish_fvg"] = BearishFVG(df) + df["bullish_fvg_retracement"], df["bearish_fvg_retracement"] = FVGRetracement(df) + df["bullish_ifvg"] = BullishIFVG(df) + df["bearish_ifvg"] = BearishIFVG(df) + + df["bullish"], df["bearish"] = BullishBearish(df) + + df["eqh"], df["eql"] = EQH_EQL(df) + df["eqh_retest"], df["eql_retest"] = EQHEQLRetest(df) + + df["bearish_mb"] = BearishMB(df) + df["bullish_mb"] = BullishMB(df) + + df["bullish_rb"], df["bearish_rb"] = RejectionBlocks(df) + + df["swing_high"], df["swing_low"] = Swings(df) + df["bullish_mss"], df["bearish_mss"] = MSS(df) + df["bullish_mss_retest"], df["bearish_mss_retest"] = MSS_Retest(df) + + df["bullish_bos"], df["bearish_bos"] = BoS(df) + df["bullish_trend"], df["bearish_trend"] = Trend(df) + df["bullish_choch"], df["bearish_choch"] = CHoCH(df) + + df["bullish_bb"] = BullishBB(df) + df["bearish_bb"] = BearishBB(df) + + df["exhaustion"] = ExhaustionCandle(df) + + df["above_support"], df["below_resistance"] = TrendLines(df) + + df["bullish_fib382"], df["bullish_fib500"], df["bullish_fib618"], df["bullish_fib786"], df["bearish_fib382"], df["bearish_fib500"], df["bearish_fib618"], df["bearish_fib786"] = Fibonacci(df) + + df = add_irl_erl(df) + + df["killzone"] = GetKillzone(df) + + """ + if "Volume" in df.columns: + df.rename(columns={ + 'Volume': 'volume', + }, inplace=True) + elif "tick_volume" in df.columns: + df.rename(columns={ + 'tick_volume': 'volume', + }, inplace=True) + """ + + df["asia_high_dist"] = AsiaHighDistance(df) + df["asia_low_dist"] = AsiaLowDistance(df) + + df["pdh_dist"] = PDHDistance(df) + df["pdl_dist"] = PDLDistance(df) + + df["pd_poc_dist"] = PDPOCDistance(df) + + df["sell_score"] = SellScore(df) + df["buy_score"] = BuyScore(df) + + df = df[["Open", "High", "Low", "Close", + "k", "k_smooth", "adx", "+di", "-di", "EMA7", "EMA21", "EMA721_DIFF", "EMA7_Slope", "EMA21_Slope", "EMA50", "EMA200", "EMA2150_DIFF", "EMA50_Slope", "EMA50200_DIFF", "EMA200_Slope", + "indecision", "bullish_ob", "bearish_ob", "bullish_fvg", "bearish_fvg", "bullish_ifvg", "bearish_ifvg", "eqh", "eql", "bearish_mb", "bullish_mb","bullish_rb", "bearish_rb", "bullish_bb", "bearish_bb", + "bullish_ob_mitigation", "bearish_ob_mitigation", "bullish_fvg_retracement", "bearish_fvg_retracement", "eqh_retest", "eql_retest", + "swing_high", "swing_low", "bullish_mss", "bearish_mss", "bullish_mss_retest", "bearish_mss_retest", + "bullish_bos", "bearish_bos", "bullish_trend", "bearish_trend", "bullish_choch", "bearish_choch", + "range_high", "range_low", "erl_high", "erl_low", "irl", + "exhaustion", + "above_support", "below_resistance", + "bullish_fib382", "bullish_fib500", "bullish_fib618", "bullish_fib786", "bearish_fib382", "bearish_fib500", "bearish_fib618", "bearish_fib786", + "vwap", "vwap_upper", "vwap_lower", "above_vwap", "below_vwap", "vwap_above_upper", "vwap_below_lower", "vwap_slope", + "range_ma", "range", + "bullish", "bearish", + "asia_high_dist", "asia_low_dist", + "pdh_dist", "pdl_dist", + "pd_poc_dist", + "killzone", + "sell_score", "buy_score", + ]].copy() + + # print("Loaded indicators") + + df.dropna(inplace=True) + return df + +class PPOxLSTMNetwork(nn.Module): + def __init__( + self, + state_size, + hidden_size=128, + action_size=3, + seq_len=32, + ): + super().__init__() + + self.embedding = nn.Linear(state_size, hidden_size) + + cfg = xLSTMBlockStackConfig( + slstm_block=sLSTMBlockConfig( + slstm=sLSTMLayerConfig( + backend="vanilla", + num_heads=4, + ) + ), + + slstm_at=[0, 1], + + context_length=seq_len, + num_blocks=2, + embedding_dim=hidden_size, + ) + + self.backbone = xLSTMBlockStack(cfg) + + # Buy / Sell / Hold + self.policy = nn.Sequential( + nn.Linear(hidden_size, 64), + nn.ReLU(), + nn.Linear(64, action_size) + ) + + # RR multiplier + self.rr_head = nn.Sequential( + nn.Linear(hidden_size, 32), + nn.ReLU(), + nn.Linear(32, 1) + ) + + # SL multiplier + self.sl_head = nn.Sequential( + nn.Linear(hidden_size, 32), + nn.ReLU(), + nn.Linear(32, 1) + ) + + # PPO critic + self.value = nn.Sequential( + nn.Linear(hidden_size, 64), + nn.ReLU(), + nn.Linear(64, 1) + ) + + def forward(self, x): + + # (batch, seq, features) + x = self.embedding(x) + + x = self.backbone(x) + + # Last timestep + h = x[:, -1] + + # Action logits + logits = self.policy(h) + + # RR between 1 and 10 + rr_mult = 1.0 + torch.sigmoid( + self.rr_head(h) + ) * 9.0 + + # SL multiplier between 0.75x and 2.0x + sl_mult = 0.75 + torch.sigmoid( + self.sl_head(h) + ) * 1.25 + + # PPO critic + value = self.value(h).squeeze(-1) + + return logits, rr_mult, sl_mult, value + +class xLSTMPPOAgent: + def __init__( + self, + state_size, + hidden_size, + action_size, + lr=3e-4, + gamma=0.95, + clip_ratio=0.2, + gae_lambda=0.95 + ): + self.state_size = state_size + self.hidden_size = hidden_size + self.action_size = action_size + + self.gamma = gamma + self.clip_ratio = clip_ratio + self.gae_lambda = gae_lambda + + self.train_epochs = 10 + self.batch_size = 64 + self.entropy_coef = 0.01 + self.value_coef = 0.5 + + self.device = torch.device( + "cuda" if torch.cuda.is_available() else "cpu" + ) + + self.model = PPOxLSTMNetwork( + state_size, + hidden_size, + action_size + ).to(self.device) + + self.optimizer = torch.optim.Adam( + self.model.parameters(), + lr=lr + ) + + self.trajectory = [] + + def _state_tensor(self, state_seq): + return torch.tensor( + state_seq, + dtype=torch.float32, + device=self.device + ).unsqueeze(0) + + def select_action(self, state_seq, in_position=False, training=False): + + state = self._state_tensor(state_seq) + + with torch.no_grad(): + logits, rr_mult, sl_mult, value = self.model(state) + + logits = logits.squeeze(0) + rr_mult = rr_mult.squeeze() + sl_mult = sl_mult.squeeze() + + valid_actions = [0] if in_position else [0,1,2] + + masked_logits = logits.clone() + + for i in range(self.action_size): + if i not in valid_actions: + masked_logits[i] = -1e9 + + probs = torch.softmax(masked_logits, dim=-1) + dist = Categorical(probs) + + action = dist.sample() if training else torch.argmax(probs) + + logprob = dist.log_prob(action) + + return ( + int(action.item()), + float(logprob.item()), + float(value.item()), + float(rr_mult.item()), + float(sl_mult.item()) + ) + + def store_transition( + self, + state_seq, + action, + logprob, + value, + rr_mult, + sl_mult, + reward, + done + ): + self.trajectory.append( + ( + np.array(state_seq, dtype=np.float32), + action, + logprob, + value, + rr_mult, + sl_mult, + reward, + done + ) + ) + + def compute_gae(self, rewards, values, dones): + + advantages = [] + gae = 0 + + values = np.append(values, 0.0) + + for t in reversed(range(len(rewards))): + + delta = ( + rewards[t] + + self.gamma * values[t + 1] * (1 - dones[t]) + - values[t] + ) + + gae = ( + delta + + self.gamma + * self.gae_lambda + * (1 - dones[t]) + * gae + ) + + advantages.insert(0, gae) + + return np.array(advantages, dtype=np.float32) + + def train(self): + + if len(self.trajectory) < 32: + return + + ( + states, + actions, + old_logprobs, + values, + rr_mults, + sl_mults, + rewards, + dones + ) = zip(*self.trajectory) + + states = torch.tensor(np.array(states), dtype=torch.float32, device=self.device) + actions = torch.tensor(actions, dtype=torch.long, device=self.device) + old_logprobs = torch.tensor(old_logprobs, dtype=torch.float32, device=self.device) + values = np.array(values, dtype=np.float32) + rewards = np.array(rewards, dtype=np.float32) + dones = np.array(dones, dtype=np.float32) + + advantages = self.compute_gae(rewards, values, dones) + returns = advantages + values + + advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8) + + returns = torch.tensor(returns, dtype=torch.float32, device=self.device) + advantages = torch.tensor(advantages, dtype=torch.float32, device=self.device) + + n = len(states) + + for _ in range(self.train_epochs): + + idx = torch.randperm(n, device=self.device) + + for start in range(0, n, self.batch_size): + + batch = idx[start:start+self.batch_size] + + logits, rr_pred, sl_pred, value_pred = self.model(states[batch]) + + dist = Categorical(logits=logits) + + new_logprob = dist.log_prob(actions[batch]) + + ratio = torch.exp(new_logprob - old_logprobs[batch]) + + surr1 = ratio * advantages[batch] + surr2 = torch.clamp( + ratio, + 1-self.clip_ratio, + 1+self.clip_ratio + ) * advantages[batch] + + policy_loss = -torch.min(surr1, surr2).mean() + + value_loss = F.mse_loss( + value_pred, + returns[batch] + ) + + entropy = dist.entropy().mean() + + loss = ( + policy_loss + + self.value_coef * value_loss + - self.entropy_coef * entropy + ) + + self.optimizer.zero_grad() + loss.backward() + torch.nn.utils.clip_grad_norm_(self.model.parameters(), 1.0) + self.optimizer.step() + + self.trajectory.clear() + + def savecheckpoint(self, symbol): + os.makedirs("LSTM-PPO-saves", exist_ok=True) + + filename = ( + f"LSTM-PPO-saves/" + f"{datetime.now().strftime('%Y-%m-%d')}-" + f"{symbol}.checkpoint.pt" + ) + + torch.save( + { + "model": self.model.state_dict(), + "optimizer": self.optimizer.state_dict() + }, + filename + ) + + def loadcheckpoint(self, symbol): + + if not os.path.exists("LSTM-PPO-saves"): + return + + files = [ + os.path.join("LSTM-PPO-saves", f) + for f in os.listdir("LSTM-PPO-saves") + if f.endswith(".checkpoint.pt") + and symbol in f + ] + + if not files: + return + + latest = max(files, key=os.path.getmtime) + + checkpoint = torch.load( + latest, + map_location=self.device + ) + + self.model.load_state_dict(checkpoint["model"]) + + if "optimizer" in checkpoint: + self.optimizer.load_state_dict( + checkpoint["optimizer"] + ) + + print(f"Loaded checkpoint: {latest}") + +class PPOLSTMNetwork(nn.Module): + def __init__(self, state_size=12, hidden_size=64, action_size=3): + super().__init__() + + # print("PPOLSTMNetwork state_size =", state_size) + + self.lstm = nn.LSTM( + input_size=state_size, + hidden_size=hidden_size, + batch_first=True + ) + + # print("LSTM input_size =", self.lstm.input_size) + + self.policy = nn.Sequential( + nn.Linear(hidden_size, 64), + nn.ReLU(), + nn.Linear(64, action_size) + ) + + self.value = nn.Sequential( + nn.Linear(hidden_size, 64), + nn.ReLU(), + nn.Linear(64, 1) + ) + + # self.debug = False + + def forward(self, x): + """ + if self.debug: + print("x shape before lstm:", x.shape) + + if x.shape[1] == 0: + print("ERROR: zero sequence length") + print("x shape:", x.shape) + raise ValueError("Zero sequence length") + """ + + out, _ = self.lstm(x) + h = out[:, -1, :] + + logits = self.policy(h) + value = self.value(h).squeeze(-1) + + return logits, value + +class LSTMPPOAgent: + def __init__( + self, + state_size, + hidden_size, + action_size, + lr=3e-4, + gamma=0.95, + clip_ratio=0.2, + gae_lambda=0.95 + ): + self.state_size = state_size + self.hidden_size = hidden_size + self.action_size = action_size + + self.gamma = gamma + self.clip_ratio = clip_ratio + self.gae_lambda = gae_lambda + + self.train_epochs = 10 + self.batch_size = 64 + self.entropy_coef = 0.01 + self.value_coef = 0.5 + + self.device = torch.device( + "cuda" if torch.cuda.is_available() else "cpu" + ) + + self.model = PPOLSTMNetwork( + state_size, + hidden_size, + action_size + ).to(self.device) + + self.optimizer = torch.optim.Adam( + self.model.parameters(), + lr=lr + ) + + self.trajectory = [] + + # print("Agent state_size =", state_size) + + def _state_tensor(self, state_seq): + return torch.tensor( + state_seq, + dtype=torch.float32, + device=self.device + ).unsqueeze(0) + + def select_action(self, state_seq, in_position=False, training=False): + + state = self._state_tensor(state_seq) + + # if training is False: + # print("state type:", type(state)) + # print("state shape:", state.shape if hasattr(state, "shape") else "no shape") + + with torch.no_grad(): + logits, value = self.model(state) + + logits = logits.squeeze(0) + + if in_position: + valid_actions = [0] + else: + valid_actions = [0, 1, 2] + + masked_logits = logits.clone() + + for i in range(self.action_size): + if i not in valid_actions: + masked_logits[i] = -1e9 + + probs = torch.softmax(masked_logits, dim=-1) + + dist = Categorical(probs) + + if training: + action = dist.sample() + else: + action = torch.argmax(probs) + + logprob = dist.log_prob(action) + + """ + print( + f"H={probs[0]:.2f} " + f"B={probs[1]:.2f} " + f"S={probs[2]:.2f}" + ) + """ + + return ( + int(action.item()), + float(logprob.item()), + float(value.item()) + ) + + def store_transition( + self, + state_seq, + action, + logprob, + value, + reward, + done + ): + self.trajectory.append( + ( + np.array(state_seq, dtype=np.float32), + action, + logprob, + value, + reward, + done + ) + ) + + def compute_gae(self, rewards, values, dones): + + advantages = [] + gae = 0 + + values = np.append(values, 0.0) + + for t in reversed(range(len(rewards))): + + delta = ( + rewards[t] + + self.gamma * values[t + 1] * (1 - dones[t]) + - values[t] + ) + + gae = ( + delta + + self.gamma + * self.gae_lambda + * (1 - dones[t]) + * gae + ) + + advantages.insert(0, gae) + + return np.array(advantages, dtype=np.float32) + + def train(self): + + if len(self.trajectory) < 32: + return + + states, actions, old_logprobs, values, rewards, dones = zip( + *self.trajectory + ) + + states = np.array(states, dtype=np.float32) + actions = np.array(actions) + old_logprobs = np.array(old_logprobs, dtype=np.float32) + values = np.array(values, dtype=np.float32) + rewards = np.array(rewards, dtype=np.float32) + dones = np.array(dones, dtype=np.float32) + + advantages = self.compute_gae( + rewards, + values, + dones + ) + + returns = advantages + values + + advantages = ( + advantages - advantages.mean() + ) / (advantages.std() + 1e-8) + + states = torch.tensor( + states, + dtype=torch.float32, + device=self.device + ) + + actions = torch.tensor( + actions, + dtype=torch.long, + device=self.device + ) + + old_logprobs = torch.tensor( + old_logprobs, + dtype=torch.float32, + device=self.device + ) + + returns = torch.tensor( + returns, + dtype=torch.float32, + device=self.device + ) + + advantages = torch.tensor( + advantages, + dtype=torch.float32, + device=self.device + ) + + n = len(states) + + for _ in range(self.train_epochs): + + idx = torch.randperm(n, device=self.device) + + for start in range(0, n, self.batch_size): + + batch_idx = idx[start:start+self.batch_size] + b_states = states[batch_idx] + b_actions = actions[batch_idx] + b_old_logprobs = old_logprobs[batch_idx] + b_returns = returns[batch_idx] + b_advantages = advantages[batch_idx] + + # print("b_states.shape =", b_states.shape) + # print("model input_size =", self.model.lstm.input_size) + logits, values_pred = self.model(b_states) + + dist = Categorical(logits=logits) + + new_logprobs = dist.log_prob( + b_actions + ) + + entropy = dist.entropy().mean() + + ratio = torch.exp( + new_logprobs - b_old_logprobs + ) + + surr1 = ratio * b_advantages + + surr2 = torch.clamp( + ratio, + 1 - self.clip_ratio, + 1 + self.clip_ratio + ) * b_advantages + + policy_loss = -torch.min( + surr1, + surr2 + ).mean() + + value_loss = F.mse_loss( + values_pred, + b_returns + ) + + loss = ( + policy_loss + + self.value_coef * value_loss + - self.entropy_coef * entropy + ) + + self.optimizer.zero_grad() + loss.backward() + + torch.nn.utils.clip_grad_norm_( + self.model.parameters(), + 1.0 + ) + + self.optimizer.step() + + self.trajectory.clear() + + def savecheckpoint(self, symbol): + + os.makedirs( + "LSTM-PPO-saves", + exist_ok=True + ) + + filename = ( + f"LSTM-PPO-saves/" + f"{datetime.now().strftime('%Y-%m-%d')}-" + f"{symbol}.checkpoint.pt" + ) + + torch.save( + { + "model": self.model.state_dict(), + "optimizer": self.optimizer.state_dict() + }, + filename + ) + + def loadcheckpoint(self, symbol): + + if not os.path.exists("LSTM-PPO-saves"): + return + + files = [ + os.path.join("LSTM-PPO-saves", f) + for f in os.listdir("LSTM-PPO-saves") + if f.endswith(".checkpoint.pt") + and symbol in f + ] + + if not files: + return + + latest = max(files, key=os.path.getmtime) + + checkpoint = torch.load( + latest, + map_location=self.device + ) + + self.model.load_state_dict( + checkpoint["model"] + ) + + if "optimizer" in checkpoint: + self.optimizer.load_state_dict( + checkpoint["optimizer"] + ) + + print(f"Loaded checkpoint: {latest}") + +class WinRateKNN: + def __init__(self, symbol, k=10): + self.k = k + self.symbol = symbol + self.states = [] + self.labels = [] # 1 = win, 0 = loss + self.model = None + + def add(self, state, is_win): + try: + state = np.array(state, dtype=np.float32).flatten() # Force all elements to float + except Exception as e: + # print("❌ Could not convert state to float:", state, "| Error:", e) + return + + if not np.all(np.isfinite(state)): + # print("⚠️ Skipping state with NaN or Inf:", state) + return + + self.states.append(state) + self.labels.append(1 if is_win else 0) + + if len(self.states) >= 100: + self._remove_redundant_neighbor() + # self.states.pop(0) + # self.labels.pop(0) + + if len(self.states) >= self.k: + self._fit() + + def _remove_redundant_neighbor(self): + if len(self.states) < 2: + return # Nothing to remove + + X = np.array(self.states) + + # Compute pairwise similarity (cosine, or use euclidean if you prefer) + sim_matrix = cosine_similarity(X) + + # Zero out diagonal (self-similarity) + np.fill_diagonal(sim_matrix, 0) + + # Compute average similarity for each row (how redundant each entry is) + redundancy_scores = sim_matrix.mean(axis=1) + + # Remove the most redundant (highest avg similarity) + idx_to_remove = np.argmax(redundancy_scores) + + del self.states[idx_to_remove] + del self.labels[idx_to_remove] + def _fit(self): + """ + Fit the KNN model with stored data. + """ + if len(self.states) < 1: + # print("⚠️ Not enough data to fit KNN.") + return + + # Safety check + k_neighbors = max(1, min(self.k, len(self.states))) + + self.model = NearestNeighbors(n_neighbors=k_neighbors, algorithm="kd_tree") + self.model.fit(self.states) + + def predict_win_rate(self, state_seq, k_near=5, k_far=5): + """ + Return the win rate based on k nearest neighbors of the input state. + """ + # if not self.model or len(self.states) < self.k: + if len(self.states) < 1000: + # return True # Not enough data + return 1 # Not enough data + + # Find the 100 nearest neighbors + distances, indices = self.model.kneighbors(state.reshape(1, -1), n_neighbors=50) + distances = distances[0] + indices = indices[0] + + # Split into nearest and farthest groups + nearest_idx = indices[:k_near] + nearest_dist = distances[:k_near] + + farthest_idx = indices[-k_far:] + farthest_dist = distances[-k_far:] + + # Combine indices and distances + combined_idx = np.concatenate([nearest_idx, farthest_idx]) + combined_dist = np.concatenate([nearest_dist, farthest_dist]) + + # Get win/loss labels for selected neighbors + selected_labels = np.array([self.labels[i] for i in combined_idx]) + + # Calculate weights (closer gets higher weight) + weights = 1 / (combined_dist + 1e-6) # Add epsilon to avoid div-by-zero + + # Normalize weights + weights /= weights.sum() + + # Compute weighted win rate + win_rate = np.dot(selected_labels, weights) + + return win_rate + def save(self): + """ + Save the KNN model to disk. + """ + path = f"LSTM-PPO-saves/{datetime.now().strftime('%Y-%m-%d')}-{self.symbol}.win_rate_knn.pkl" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + pickle.dump({ + "states": self.states, + "labels": self.labels, + "model": self.model + }, f) + + def load(self): + """ + Load the KNN model from disk. + """ + # path = f"LSTM-PPO-saves/win_rate_knn-{self.symbol}.pkl" + files = sorted(os.listdir("LSTM-PPO-saves")) + files = [f for f in files if f.endswith(".win_rate_knn.pkl") and self.symbol in f] + if not files: + print(f"[!] No checkpoint found for {self.symbol}") + return + + latest = os.path.join("LSTM-PPO-saves", files[-1]) + + try: + with open(latest, "rb") as f: + data = pickle.load(f) + self.states = data["states"] + self.labels = data["labels"] + self.model = data["model"] + # print(f"✅ Loaded WinRateKNN from {latest}") + except FileNotFoundError: + print(f"⚠️ No saved KNN found at {path}. Starting fresh.") + +def sharpe_ratio(returns, risk_free_rate=0.0): + mean_ret = np.mean(returns) + std_ret = np.std(returns) + if std_ret == 0: + return 0 + return (mean_ret - risk_free_rate) / std_ret + +def sortino_ratio(returns, risk_free_rate=0.0): + mean_ret = np.mean(returns) + # Downside deviation: only consider returns below risk-free rate, and their square differences + downside_diff = [(r - risk_free_rate)**2 for r in returns if r < risk_free_rate] + + if len(downside_diff) == 0: + return 0 # Or float('inf') if you'd rather signal perfect performance + + downside_std = np.sqrt(np.mean(downside_diff)) + + if downside_std == 0: + return 0 + + return (mean_ret - risk_free_rate) / downside_std + +def rolling_sharpe(trade_returns_50): + if len(trade_returns_50) < 10: + return 0.0 + + r = np.asarray(trade_returns_50, dtype=np.float32) + + std = r.std() + if std < 1e-8: + return 0.0 + + return r.mean() / std + +def max_drawdown(returns): + + if len(returns) == 0: + return 0 + + equity = np.cumsum(returns) + + peak = equity[0] + max_dd = 0 + + for value in equity: + + peak = max(peak, value) + + dd = peak - value + + max_dd = max(max_dd, dd) + + return max_dd + +def SelectAction(df, i): + + # Session filter + if df["killzone"].iloc[i] not in (1, 2, 3): # Asia, London, NY + return 0 + + # Trend strength + if df["adx"].iloc[i] < 20: + return 0 + + buy = 0 + sell = 0 + + # ------------------------- + # OB mitigation + # ------------------------- + buy += int(df["bullish_ob_mitigation"].iloc[i]) + sell += int(df["bearish_ob_mitigation"].iloc[i]) + + # Breaker block + buy += int(df["bullish_bb"].iloc[i]) + sell += int(df["bearish_bb"].iloc[i]) + + # MSS + buy += int( + df["bullish_mss"].iloc[i] and + not df["bullish_trend"].iloc[i] + ) + + sell += int( + df["bearish_mss"].iloc[i] and + not df["bearish_trend"].iloc[i] + ) + + # MSS Retest + buy += int(df["bullish_mss_retest"].iloc[i]) + sell += int(df["bearish_mss_retest"].iloc[i]) + + # FVG + MB/RB + buy += int( + df["bullish_fvg_retracement"].iloc[i] + and (df["bullish_mb"].iloc[i] or df["bullish_rb"].iloc[i]) + ) + + sell += int( + df["bearish_fvg_retracement"].iloc[i] + and (df["bearish_mb"].iloc[i] or df["bearish_rb"].iloc[i]) + ) + + # Asia High + buy += int(0 <= df["asia_high_dist"].iloc[i] <= 3) + sell += int(-3 <= df["asia_high_dist"].iloc[i] <= 0) + + # Asia Low + buy += int(-3 <= df["asia_low_dist"].iloc[i] <= 0) + sell += int(0 <= df["asia_low_dist"].iloc[i] <= 3) + + # PDH + buy += int(0 <= df["pdh_dist"].iloc[i] <= 3) + sell += int(-3 <= df["pdh_dist"].iloc[i] <= 0) + + # PDL + buy += int(-3 <= df["pdl_dist"].iloc[i] <= 0) + sell += int(0 <= df["pdl_dist"].iloc[i] <= 3) + + # Equal High / Equal Low + buy += int(df["eql"].iloc[i]) + sell += int(df["eqh"].iloc[i]) + + # 0.382, 0.5, 0.618 retracement + buy += int(df["bullish_fib382"].iloc[i]) + sell += int(df["bearish_fib382"].iloc[i]) + buy += int(df["bullish_fib500"].iloc[i]) + sell += int(df["bearish_fib500"].iloc[i]) + buy += int(df["bullish_fib618"].iloc[i]) + sell += int(df["bearish_fib618"].iloc[i]) + + # Final decision + if buy >= 2 and buy > sell: + return 1 + + if sell >= 2 and sell > buy: + return 2 + + return 0 + +def train_bot(symbol="XAUUSD"): + + print("Training bot") + start = time.perf_counter() + + df = load_last_mb_xauusd() + + print(f"Loading indicators... ({time.strftime('%H:%M')})") + + df = add_indicators(df) + + start = time.perf_counter() + # elapsed = int((time.perf_counter() - start) // 60) + elapsed = int((time.perf_counter() - start)) + print(f"Loaded indicators (Elapsed: {elapsed}m)") + print(f"Loading checkpoint....") + + + SEQ_LEN = 12 * 3 + + save_count = 1440 + + FEATURES = [ + "k", "k_smooth", "adx", "+di", "-di", "EMA7", "EMA21", "EMA721_DIFF", "EMA7_Slope", "EMA21_Slope", "EMA50", "EMA200", "EMA2150_DIFF", "EMA50_Slope", "EMA50200_DIFF", "EMA200_Slope", + "indecision", "bullish_ob", "bearish_ob", "bullish_fvg", "bearish_fvg", "bullish_ifvg", "bearish_ifvg", "eqh", "eql", "bearish_mb", "bullish_mb","bullish_rb", "bearish_rb", "bullish_bb", "bearish_bb", + "bullish_ob_mitigation", "bearish_ob_mitigation", "bullish_fvg_retracement", "bearish_fvg_retracement", "eqh_retest", "eql_retest", + "swing_high", "swing_low", "bullish_mss", "bearish_mss", "bullish_mss_retest", "bearish_mss_retest", + "bullish_bos", "bearish_bos", "bullish_trend", "bearish_trend", "bullish_choch", "bearish_choch", + "range_high", "range_low", "erl_high", "erl_low", "irl", + "exhaustion", + "above_support", "below_resistance", + "bullish_fib382", "bullish_fib500", "bullish_fib618", "bullish_fib786", "bearish_fib382", "bearish_fib500", "bearish_fib618", "bearish_fib786", + "above_vwap", "below_vwap", "vwap_above_upper", "vwap_below_lower", "vwap_slope", + "range_ma", "range", + "bullish", "bearish", + "asia_high_dist", "asia_low_dist", + "pdh_dist", "pdl_dist", + "pd_poc_dist", + "killzone", + "sell_score", "buy_score" + ] + + agent = xLSTMPPOAgent( + state_size=len(FEATURES), + hidden_size=64, + action_size=3 + ) + + agent.loadcheckpoint(symbol) + print(f"Loaded checkpoint... ({time.strftime('%H:%M')})") + + save_counter = 0 + + in_position = False + position_type = None + + entry_price = 0 + sl_price = 0 + tp1_price = 0.0 + tp2_price = 0.0 + + trade1_open = False + trade2_open = False + + be_moved = False + + trade_returns = [] + + rr_list = [] + SPREAD_AND_COMMISSION = 6 + + sl_pips = 0 + sl_list = [] + + PIP_VALUE = 0.1 + + training_start_2 = time.time() + training_start_3 = time.time() + + state_buffer = [] + + for i in range(len(df)): + row = df.iloc[i][FEATURES].values.astype(np.float32) + state_buffer.append(row) + + # ------------------------------------------------------------------ + # Week / month / recovery-factor training config + # ------------------------------------------------------------------ + WEEK_SIZE = save_count # 1440 M5 candles ≈ 1 trading week + WEEKS_PER_MONTH = 4 # agent.trajectory is reset every 4 weeks + RF_TARGET = 1.5 # keep retraining a week until RF exceeds this + MAX_ATTEMPTS_PER_WEEK = 20 # safety cap so a "dead" week can't hang forever + LOOP_FOREVER = True # True: wrap back to the start of df and repeat + # False: stop once len(df) has been covered + + total_weeks = max(1, math.ceil((len(df) - SEQ_LEN) / WEEK_SIZE)) + + week_idx = 0 + week_start = SEQ_LEN + + while True: + + # Wrap back to the beginning of df once we've covered it all + if week_start >= len(df): + if not LOOP_FOREVER: + break + week_start = SEQ_LEN + week_idx = 0 + + week_end = min(week_start + WEEK_SIZE, len(df)) + + # Reset the trajectory at the start of every new "month" + if week_idx % WEEKS_PER_MONTH == 0: + agent.trajectory.clear() + + rf = 0.0 + attempt = 0 + + # ============================================================== + # Keep retraining THIS week until its recovery factor beats the + # target, then fall through and move on to the next week. + # ============================================================== + while rf <= RF_TARGET and attempt < MAX_ATTEMPTS_PER_WEEK: + + attempt += 1 + + # Per-attempt trading state resets (replaying the same week) + in_position = False + position_type = None + entry_price = 0 + sl_price = 0 + tp1_price = 0.0 + tp2_price = 0.0 + trade1_open = False + trade2_open = False + be_moved = False + + trade_returns = [] + rr_list = [] + sl_list = [] + sl_pips = 0 + + pending_entry = None # caches the decision that opened the current trade + + training_start_3 = time.time() + + for i in range(week_start, week_end): + + current = df.iloc[i] + + current_price = current["Close"] + high = current["High"] + low = current["Low"] + base_sl = 50 + + state_seq = np.array(df.iloc[i - SEQ_LEN:i][FEATURES], dtype=np.float32) + + result = agent.select_action(state_seq, in_position, training=True) + + if result is None: + continue + + action, logprob, value, rr_mult, sl_mult = result + rr_list.append(rr_mult) + + pnl = 0.0 + done = False + + if in_position: + action = 0 + + # ========================================================== + # OPEN LONG + # ========================================================== + if action == 1: + + in_position = True + trade1_open = True + trade2_open = True + position_type = "long" + be_moved = False + + entry_price = current_price + + sl_pips = np.clip(base_sl * sl_mult, 20, 50) + sl_list.append(sl_pips) + + sl_price = entry_price - sl_pips * PIP_VALUE + full_tp_distance = sl_pips * rr_mult * PIP_VALUE + + tp1_price = entry_price + full_tp_distance * 0.5 + tp2_price = entry_price + full_tp_distance + + # Remember what produced this trade so the eventual + # outcome is credited to THIS decision, not to a later + # unrelated one. + pending_entry = dict( + state_seq=state_seq, + action=action, + logprob=logprob, + value=value, + rr_mult=rr_mult, + sl_mult=sl_mult, + ) + + # ========================================================== + # OPEN SHORT + # ========================================================== + elif action == 2: + + in_position = True + trade1_open = True + trade2_open = True + position_type = "short" + be_moved = False + + entry_price = current_price + + sl_pips = np.clip(base_sl * sl_mult, 20, 50) + sl_list.append(sl_pips) + + sl_price = entry_price + sl_pips * PIP_VALUE + full_tp_distance = sl_pips * rr_mult * PIP_VALUE + + tp1_price = entry_price - full_tp_distance * 0.5 + tp2_price = entry_price - full_tp_distance + + pending_entry = dict( + state_seq=state_seq, + action=action, + logprob=logprob, + value=value, + rr_mult=rr_mult, + sl_mult=sl_mult, + ) + + # ========================================================== + # MANAGE POSITION + # ========================================================== + if in_position: + + trade_closed = False + + if position_type == "long": + + if trade1_open and high >= tp1_price: + pnl += sl_pips * rr_mult * 0.5 - SPREAD_AND_COMMISSION + trade1_open = False + if not be_moved: + sl_price = entry_price + be_moved = True + + if trade2_open and high >= tp2_price: + pnl += sl_pips * rr_mult - SPREAD_AND_COMMISSION + trade2_open = False + trade_closed = True + + if not trade_closed and low <= sl_price: + if sl_price != entry_price: + pnl -= sl_pips * 2 + SPREAD_AND_COMMISSION * 2 + trade1_open = False + trade2_open = False + trade_closed = True + + elif position_type == "short": + + if trade1_open and low <= tp1_price: + pnl += sl_pips * rr_mult * 0.5 - SPREAD_AND_COMMISSION + trade1_open = False + if not be_moved: + sl_price = entry_price + be_moved = True + + if trade2_open and low <= tp2_price: + pnl += sl_pips * rr_mult - SPREAD_AND_COMMISSION + trade2_open = False + trade_closed = True + + if not trade_closed and high >= sl_price: + if sl_price != entry_price: + pnl -= sl_pips * 2 + SPREAD_AND_COMMISSION * 2 + trade1_open = False + trade2_open = False + trade_closed = True + + # Any close — win via TP2 or loss via SL — ends the episode. + if trade_closed: + in_position = False + done = True + trade_returns.append(pnl) + + if pending_entry is not None: + reward = pnl / 50.0 # ~R-multiple, stable scale + + agent.store_transition( + pending_entry["state_seq"], + pending_entry["action"], + pending_entry["logprob"], + pending_entry["value"], + rr_mult=pending_entry["rr_mult"], + sl_mult=pending_entry["sl_mult"], + reward=reward, + done=True, + ) + pending_entry = None + + pnl = 0 + + # ========================================================== + # END OF THIS PASS OVER THE WEEK: compute stats + RF + # ========================================================== + + if len(trade_returns) > 5: + + wins = [r for r in trade_returns if r > 0] + losses = [r for r in trade_returns if r < 0] + + weekly_pnl = np.sum(trade_returns) + + winrate = ( + len(wins) / len(trade_returns) + if len(trade_returns) > 0 else 0 + ) + + mean_win = ( + np.mean(wins) + if len(wins) > 0 else 0 + ) + + mean_loss = ( + np.mean(losses) + if len(losses) > 0 else 0 + ) + + sharpe = sharpe_ratio(trade_returns) + sortino = sortino_ratio(trade_returns) + + gross_profit = sum(wins) + gross_loss = abs(sum(losses)) + profit_factor = ( + gross_profit / gross_loss + if gross_loss > 0 + else float("inf") + ) + avg_rr = sum(rr_list) / len(rr_list) if rr_list else 0 + avg_sl = sum(sl_list) / len(sl_list) if sl_list else 0 + max_dd = max_drawdown(trade_returns) + R_pnl = weekly_pnl / avg_sl if avg_sl else 0 + + if max_dd > 0 and avg_sl > 0: + rf = R_pnl / (max_dd / ((avg_sl / 10) * 2)) + else: + rf = 0.0 + + print() + print("================================================") + print(f"[{symbol}] WEEK {week_idx + 1}/{total_weeks} " + f"(attempt {attempt}) PPO TRAINING") + print("================================================") + print(f"Trades: {len(trade_returns)}") + print(f"Weekly PnL: {weekly_pnl:.0f} pips") + print(f"Winrate: {winrate*100:.2f}%") + print(f"Mean Win: {mean_win:.0f} pips") + print(f"Mean Loss: {mean_loss:.0f} pips") + if avg_sl: + print(f"Max DD: {max_dd/(avg_sl*2):.2f}R") + print(f"PF: {profit_factor:.2f}") + print(f"RF: {rf:.2f}") + if avg_sl: + print(f"Weekly R PnL: {R_pnl/2:.2f}R") + print(f"Sharpe: {sharpe:.2f}") + print(f"Sortino: {sortino:.2f}") + print("================================================") + print( + f"[{symbol}] [INFO] Trained on week " + f"(Elapsed: {timedelta(seconds=int(time.time() - training_start_3))})" + ) + print() + + else: + # Not enough trades to trust the RF number — force a retry + rf = 0.0 + print( + f"[{symbol}] Week {week_idx + 1}/{total_weeks} " + f"attempt {attempt}: only {len(trade_returns)} trades, retrying..." + ) + + training_start = time.time() + print(f"[{symbol}] [INFO] Training PPO on week {week_idx + 1} " + f"(attempt {attempt})...") + agent.train() + print( + f"[{symbol}] " + f"[INFO] Finished training PPO " + f"(Elapsed: {timedelta(seconds=int(time.time() - training_start))})" + ) + agent.savecheckpoint(symbol) + + if attempt >= MAX_ATTEMPTS_PER_WEEK: + print( + f"[{symbol}] [WARN] Week {week_idx + 1} hit " + f"MAX_ATTEMPTS_PER_WEEK ({MAX_ATTEMPTS_PER_WEEK}) " + f"without RF > {RF_TARGET} (last RF={rf:.2f}); moving on anyway." + ) + + elapsed_total = time.time() - training_start_2 + print( + f"[{symbol}] [INFO] Week {week_idx + 1}/{total_weeks} done " + f"(RF={rf:.2f} > {RF_TARGET} after {attempt} attempt(s)) | " + f"Total elapsed: {timedelta(seconds=int(elapsed_total))}" + ) + + # Advance to the next week + week_start = week_end + week_idx += 1 + + agent.train() + agent.savecheckpoint(symbol) + + print( + f"[{symbol}] " + f"[INFO] Finished training " + ) + + return agent + +def open_long(symbol, lot_size, sl_pips, rr_mult): + + tick = mt5.symbol_info_tick(symbol) + + entry = tick.ask + + sl = entry - sl_pips / 10 + + tp1 = entry + sl_pips / 10 * rr_mult + + tps = [tp1] + + for tp in tps: + + request = { + "action": mt5.TRADE_ACTION_DEAL, + "symbol": symbol, + "volume": lot_size, + "type": mt5.ORDER_TYPE_BUY, + "price": entry, + "sl": sl, + "tp": tp, + "deviation": 20, + "magic": 123456, + "comment": "Project Helios", + "type_time": mt5.ORDER_TIME_GTC, + "type_filling": mt5.ORDER_FILLING_IOC + } + + result = mt5.order_send(request) + + if result.retcode != mt5.TRADE_RETCODE_DONE: + print(f"Order failed: {result.retcode}") + return None + + # return result.position + + # print(result) + +def open_short(symbol, lot_size, sl_pips, rr_mult): + + tick = mt5.symbol_info_tick(symbol) + + entry = tick.bid + + sl = entry + sl_pips / 10 + + tp1 = entry - sl_pips / 10 * rr_mult, + + tps = [tp1] + + for tp in tps: + + request = { + "action": mt5.TRADE_ACTION_DEAL, + "symbol": symbol, + "volume": lot_size, + "type": mt5.ORDER_TYPE_SELL, + "price": entry, + "sl": sl, + "tp": tp, + "deviation": 20, + "magic": 123456, + "comment": "Project Helios", + "type_time": mt5.ORDER_TIME_GTC, + "type_filling": mt5.ORDER_FILLING_IOC + } + + result = mt5.order_send(request) + + if result.retcode != mt5.TRADE_RETCODE_DONE: + print(f"Order failed: {result.retcode}") + return None + + # return result.position + + # print(result) + +def open_positions(symbol): + positions = mt5.positions_get(symbol=symbol) + positions = [ + p + for p in positions + if p.magic == 123456 + ] + return len(positions) + +def close_trades(magic=123456): + positions = mt5.positions_get() + + if positions is None: + print("Failed to get positions:", mt5.last_error()) + return + + for pos in positions: + if pos.magic != magic: + continue + + tick = mt5.symbol_info_tick(pos.symbol) + if tick is None: + continue + + if pos.type == mt5.POSITION_TYPE_BUY: + order_type = mt5.ORDER_TYPE_SELL + price = tick.bid + else: + order_type = mt5.ORDER_TYPE_BUY + price = tick.ask + + request = { + "action": mt5.TRADE_ACTION_DEAL, + "symbol": pos.symbol, + "volume": pos.volume, + "type": order_type, + "position": pos.ticket, + "price": price, + "deviation": 20, + "magic": magic, + "comment": "End of Day", + "type_time": mt5.ORDER_TIME_GTC, + "type_filling": mt5.ORDER_FILLING_IOC, + } + + result = mt5.order_send(request) + + if result.retcode != mt5.TRADE_RETCODE_DONE: + print(f"Failed to close {pos.ticket}: {result.retcode}") + else: + print(f"Closed {pos.ticket}") + +def modify_sl(): + + positions = mt5.positions_get() + + if positions is None: + return False + + # Only our EA's positions + positions = [p for p in positions if p.magic == 123456] + + # Only modify if exactly one position remains + if len(positions) != 1: + return False + + position = positions[0] + + # Already at breakeven? + if abs(position.sl - position.price_open) < 0.01: + return True + + request = { + "action": mt5.TRADE_ACTION_SLTP, + "position": position.ticket, + "symbol": position.symbol, + "sl": position.price_open, + "tp": position.tp, + } + + result = mt5.order_send(request) + + return result.retcode == mt5.TRADE_RETCODE_DONE + +def test_bot(symbol="XAUUSD"): + SEQ_LEN = 12 * 3 + + mt5.initialize() + account = mt5.account_info() + balance = account.balance + RISK = 0.0005 + + FEATURES = [ + # "Open", "High", "Low", "Close", + "k", "k_smooth", "adx", "+di", "-di", "EMA7", "EMA21", "EMA721_DIFF", "EMA7_Slope", "EMA21_Slope", "EMA50", "EMA200", "EMA2150_DIFF", "EMA50_Slope", "EMA50200_DIFF", "EMA200_Slope", + "indecision", "bullish_ob", "bearish_ob", "bullish_fvg", "bearish_fvg", "bullish_ifvg", "bearish_ifvg", "eqh", "eql", "bearish_mb", "bullish_mb","bullish_rb", "bearish_rb", "bullish_bb", "bearish_bb", + "bullish_ob_mitigation", "bearish_ob_mitigation", "bullish_fvg_retracement", "bearish_fvg_retracement", "eqh_retest", "eql_retest", + "swing_high", "swing_low", "bullish_mss", "bearish_mss", "bullish_mss_retest", "bearish_mss_retest", + "bullish_bos", "bearish_bos", "bullish_trend", "bearish_trend", "bullish_choch", "bearish_choch", + "range_high", "range_low", "erl_high", "erl_low", "irl", + "exhaustion", + "above_support", "below_resistance", + "bullish_fib382", "bullish_fib500", "bullish_fib618", "bullish_fib786", "bearish_fib382", "bearish_fib500", "bearish_fib618", "bearish_fib786", + #"vwap", "vwap_upper", "vwap_lower", + "above_vwap", "below_vwap", "vwap_above_upper", "vwap_below_lower", "vwap_slope", + "range_ma", "range", + "bullish", "bearish", + "asia_high_dist", "asia_low_dist", + "pdh_dist", "pdl_dist", + "pd_poc_dist", + "killzone", + "sell_score", "buy_score" + ] + + # last_m15 = None + last_m5 = None + + agent = xLSTMPPOAgent( + state_size=len(FEATURES), + hidden_size=64, + action_size=3 + ) + + # agent.model.debug = True + # try: + agent.loadcheckpoint("XAUUSD") + # except: + # print("No file for prior training, cancelling test.") + # return + + # ========================================================== + # INITIAL LOAD + # ========================================================== + + rates_m5 = mt5.copy_rates_from_pos( + symbol, + mt5.TIMEFRAME_M5, + 0, + 600 + ) + + df = pd.DataFrame(rates_m5) + + df.rename(columns={ + 'open': 'Open', + 'high': 'High', + 'low': 'Low', + 'close': 'Close', + 'time': 'Date', + "tick_volume": "Volume" + }, inplace=True) + # print(f"columns: {df.columns}") + + # df["Date"] = pd.to_datetime(df["Date"], unit="s") + df["Date"] = ( + pd.to_datetime(df["Date"], unit="s", utc=True) + # .dt.tz_convert("Europe/Helsinki") + ) + df.set_index("Date", inplace=True) + + raw_df = df + + df = add_indicators(df) + + last_m5 = df.index[-1] + + # trade = [] + + # ========================================================== + # MAIN LOOP + # ========================================================== + + # print("entering main loop in test function") + while True: + + # print("in main loop") + now = datetime.now() + position_multiplier = 1 + + seconds_until_next_5m = ( + (5 - now.minute % 5) * 60 + - now.second + - now.microsecond / 1_000_000 + ) + # print(f"sleeping {seconds_until_next_5m:.0f} seconds, current time: {datetime.now()}") + if seconds_until_next_5m <= 0: + seconds_until_next_5m += 30 + + time.sleep(seconds_until_next_5m) + # print(f"slept {seconds_until_next_5m:.0f} seconds, current time: {datetime.now()}") + + tick = mt5.symbol_info_tick(symbol) + # SL_PIPS = round(tick.bid * 0.00125 * 10, 0) + # SL_PIPS = 40 + + # ====================================================== + # CHECK FOR NEW M15 CANDLE + # ====================================================== + + new_m5 = mt5.copy_rates_from_pos( + symbol, + mt5.TIMEFRAME_M5, + 0, + 1 + ) + + current_m5 = new_m5[0]["time"] + + while True: + if current_m5 != last_m5: + break + else: + time.sleep(0.25) + new_m5 = mt5.copy_rates_from_pos( + symbol, + mt5.TIMEFRAME_M5, + 0, + 1 + ) + current_m5 = new_m5[0]["time"] + + # current_m5 = new_m5[0]["time"] + + if current_m5 != last_m5: + + last_m5 = current_m5 + + # ================================================== + # APPEND NEW CANDLE + # ================================================== + + new_row = pd.DataFrame(new_m5) + + new_row.rename(columns={ + 'open': 'Open', + 'high': 'High', + 'low': 'Low', + 'close': 'Close', + 'time': 'Date', + "tick_volume": "Volume" + }, inplace=True) + + new_row["Date"] = ( + pd.to_datetime(new_row["Date"], unit="s", utc=True) + # .dt.tz_convert("Europe/Helsinki") + ) + new_row.set_index("Date", inplace=True) + + if new_row.index[-1] != df.index[-1]: + + raw_df = pd.concat( + [raw_df, new_row] + ) + + raw_df = raw_df.tail(600) + + df = add_indicators(raw_df.copy()) + + # ================================================== + # BUILD STATE SEQUENCE + # ================================================== + + state_seq = ( + df[FEATURES] + .tail(SEQ_LEN) + .values + .astype(np.float32) + ) + + # ================================================== + # POSITION CHECK + # ================================================== + + open_pos = open_positions(symbol) + + # ================================================== + # PPO DECISION + # ================================================== + + if state_seq.shape[0] != SEQ_LEN: + print(f"Bad state shape: {state_seq.shape}") + continue + + result = agent.select_action(state_seq, open_pos > 0, training=True) + + if result is None: + continue + + action, logprob, value, rr_mult, sl_mult = result + + # if value < 0: + # value = value * -1 + + current_time = df.index[-1] # or however you store timestamps + # print(f"time: {current_time.hour}h{current_time.minute}m") + + base_sl = max(10, df["Volume"].iloc[-1] / 100) + + if current_time.hour == 23 and current_time.minute >= 54: + # Force close any open position + if open_pos != 0: + close_trades() + + # Force HOLD + action = 0 + + # print(f"adx: {df['adx'].iloc[-1]}") + # print(f"+di: {df['+di'].iloc[-1]}") + # print(f"-di: {df['-di'].iloc[-1]}") + # print(f"stoch k: {df['k'].iloc[-1]}") + # print(f"stock d: {df['k_smooth'].iloc[-1]}") + + # if df["adx"].iloc[-1] < 20: + # action = 0 + + # print(f"Test action: {action}") + + # ================================================== + # OPEN NEW TRADE + # ================================================== + + if open_pos == 0: + + # trade = None + + account = mt5.account_info() + + balance = account.balance + + # risk_per_position = max( + # balance * RISK / 500 / 4, + # 0.01 + # ) + + # if action == 1 and df["adx"].iloc[-1] > 20 and df["+di"].iloc[-1] > df["-di"].iloc[-1] and df["EMA_DIFF"].iloc[-1] > 0 and df["k"].iloc[-1] < 80: + # if action == 1 and df["buy_score"].iloc[-1] > df["sell_score"].iloc[-1]: + # if action == 1 and df["EMA7"].iloc[-1] > df["EMA21"].iloc[-1] and df["k"].iloc[-1] < 80: + if action == 1: + """ + if df["above_vwap"].iloc[-1] == 1: + position_multiplier = 2 + if df["vwap_above_upper"].iloc[-1] == 1: + position_multiplier = 3 + """ + # print( + # f"[{symbol}] PPO BUY" + # ) + + sl_pips = np.clip( + base_sl * sl_mult, + 20, + 50 + ) + + risk_per_position = min( + max((balance * RISK) / (sl_pips* 10), 0.01), + 100.0 + ) + risk_per_position = round(risk_per_position, 2) + + # sl_price = entry_price - (sl_pips * PIP_VALUE) + # if value < 0: + # value = value * -1 + # tp_price = entry_price + ( + # base_sl * rr_mult * PIP_VALUE + # # 2 + # ) + + half_lot = max(round(risk_per_position / 2, 2), 0.01) + + open_long( + symbol, + half_lot, + sl_pips, + rr_mult * 0.5 + ) + + open_long( + symbol, + half_lot, + sl_pips, + rr_mult + ) + + # trade.append({ + # "tp1_ticket": ticket1, + # "tp2_ticket": ticket2, + # "entry": df["Close"].iloc[-1], + # "sl": df["Close"].iloc[-1] - sl_pips / 10, + # "breakeven": False, + # }) + + # elif action == 2 and df["adx"].iloc[-1] > 20 and df["-di"].iloc[-1] > df["+di"].iloc[-1] and df["EMA_DIFF"].iloc[-1] < 0 and df["k"].iloc[-1] > 20: + # elif action == 2 and df["buy_score"].iloc[-1] < df["sell_score"].iloc[-1]: + # elif action == 2 and df["EMA7"].iloc[-1] < df["EMA21"].iloc[-1] and df["k"].iloc[-1] > 20: + elif action == 2: + """ + if df["below_vwap"].iloc[-1] == 1: + position_multiplier = 2 + if df["vwap_below_lower"].iloc[-1] == 1: + position_multiplier = 3 + """ + # print( + # f"[{symbol}] PPO SELL" + # ) + + sl_pips = np.clip( + base_sl * sl_mult, + 20, + 50 + ) + + risk_per_position = min( + max((balance * RISK) / (sl_pips* 10), 0.01), + 100.0 + ) + risk_per_position = round(risk_per_position, 2) + + # sl_price = entry_price + (sl_pips * PIP_VALUE) + # if value < 0: + # value = value * -1 + # tp_price = entry_price - ( + # base_sl * rr_mult * PIP_VALUE + # # 2 + # ) + + half_lot = max(round(risk_per_position / 2, 2), 0.01) + + open_short( + symbol, + half_lot, + sl_pips, + rr_mult * 0.5 + ) + + open_short( + symbol, + half_lot, + sl_pips, + rr_mult + ) + + # trade.append({ + # "tp1_ticket": ticket1, + # "tp2_ticket": ticket2, + # "entry": df["Close"].iloc[-1], + # "sl": df["Close"].iloc[-1] + sl_pips / 10, + # "breakeven": False, + # }) + + # else: + + # print( + # f"[{symbol}] PPO HOLD" + # ) + else: + positions = mt5.positions_get() + + if positions is not None: + + # Only this EA's positions + positions = [p for p in positions if p.magic == 123456] + + # TP1 has closed, only TP2 remains + if len(positions) == 1: + modify_sl() + +def get_last_date(): + + if not os.path.exists(CSV_FILE): + return None + + df = pd.read_csv( + CSV_FILE, + sep=";" + ) + + if df.empty: + return None + + return pd.to_datetime( + df["Date"].iloc[-1] + ) + +def download_xauusd_data(): + + last_date = get_last_date() + + if ( + last_date is not None + and ( + datetime.now().date() + - last_date.date() + ).days <= 90 + ): + + print( + "Data already up to date." + ) + + return None + + if last_date is None: + + start_date = ( + datetime.now() + - timedelta(days=365 * 3) + ).strftime( + "%Y-%m-%d" + ) + + else: + + start_date = ( + last_date + - timedelta(days=1) + ).strftime( + "%Y-%m-%d" + ) + + end_date = ( + datetime.now() + - timedelta(days=1) + ).strftime( + "%Y-%m-%d" + ) + + print( + f"Downloading " + f"{start_date} -> {end_date}" + ) + + subprocess.run( + [ + # "npx", + "dukascopy-node", + "-i", + "xauusd", + "-from", + start_date, + "-to", + end_date, + "-t", + "m5", + "-f", + "csv" + ], + check=True + ) + + files = [ + f + for f in os.listdir(".") + if f.startswith("xauusd") + and f.endswith(".csv") + ] + + if not files: + + raise FileNotFoundError( + "No Dukascopy CSV was downloaded." + ) + + return max( + files, + key=os.path.getmtime + ) + +def append_xauusd_data(downloaded_file): + + if downloaded_file is None: + return + + new_df = pd.read_csv( + downloaded_file + ) + + new_df.rename( + columns={ + "timestamp": "Date", + "open": "Open", + "high": "High", + "low": "Low", + "close": "Close", + "volume": "Volume" + }, + inplace=True + ) + + if os.path.exists(CSV_FILE): + + old_df = pd.read_csv( + CSV_FILE, + sep=";" + ) + + df = pd.concat( + [ + old_df, + new_df + ], + ignore_index=True + ) + + else: + + df = new_df + + df.drop_duplicates( + subset=["Date"], + keep="last", + inplace=True + ) + + df.sort_values( + "Date", + inplace=True + ) + + df.to_csv( + CSV_FILE, + sep=";", + index=False + ) + + os.remove( + downloaded_file + ) + + print( + f"Saved " + f"{len(df)} candles " + f"to {CSV_FILE}" + ) + +def update_xauusd_data(): + + downloaded_file = ( + download_xauusd_data() + ) + + append_xauusd_data( + downloaded_file + ) + +def main(): + + parser = argparse.ArgumentParser() + + parser.add_argument("--train", action="store_true") + parser.add_argument("--test", action="store_true") + parser.add_argument("--symbol", default="XAUUSD-VIP") + + args = parser.parse_args() + + threads = [] + + if args.train: + t = threading.Thread( + target=train_bot, + # args=(args.symbol), + daemon=True + ) + t.start() + threads.append(t) + + if args.test: + t = threading.Thread( + target=test_bot, + args=(args.symbol,), + daemon=True + ) + t.start() + threads.append(t) + + for t in threads: + t.join() + +main()