Merge remote main: resolve conflicts keeping scalping strategy changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,17 +3,7 @@
|
||||
"allow": [
|
||||
"Bash(python -c:*)",
|
||||
"Bash(ssh:*)",
|
||||
"Bash(scp:*)",
|
||||
"WebFetch(domain:docs.alpaca.markets)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(python -m json.tool:*)",
|
||||
"Bash(python3:*)",
|
||||
"WebFetch(domain:sag.sh)",
|
||||
"WebFetch(domain:summarize.sh)",
|
||||
"Bash(git init:*)",
|
||||
"Bash(git remote add:*)",
|
||||
"Bash(git remote set-url:*)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(scp:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+16
-24
@@ -1,6 +1,3 @@
|
||||
# Configuration
|
||||
config/config.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -10,30 +7,24 @@ __pycache__/
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.eggs/
|
||||
|
||||
# Data & Models (root level only)
|
||||
/data/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.pkl
|
||||
*.h5
|
||||
/models/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Data
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
# Config (keep examples)
|
||||
config/auto_config.json
|
||||
config/auto_config.json.backup*
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -46,6 +37,7 @@ data/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
OHLCV Candle Cache
|
||||
Fetches historical data via yfinance (stocks) or OANDA (forex), caches in SQLite.
|
||||
"""
|
||||
|
||||
import yfinance as yf
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class CandleCache:
|
||||
"""Fetches OHLCV data and caches in SQLite"""
|
||||
|
||||
def __init__(self, store, oanda_broker=None):
|
||||
self.store = store
|
||||
self.oanda = oanda_broker
|
||||
|
||||
def _is_forex(self, symbol: str) -> bool:
|
||||
"""Check if symbol is a forex pair (OANDA format: XXX_YYY)"""
|
||||
return '_' in symbol and len(symbol) == 7
|
||||
|
||||
def fetch_and_cache(self, symbol: str, timeframe: str = '1h',
|
||||
lookback_days: int = 90) -> Optional[pd.DataFrame]:
|
||||
"""
|
||||
Fetch candles, store in DB, return DataFrame.
|
||||
Routes to OANDA for forex pairs, yfinance for stocks.
|
||||
"""
|
||||
if self._is_forex(symbol):
|
||||
return self._fetch_oanda(symbol, timeframe, lookback_days)
|
||||
else:
|
||||
return self._fetch_yfinance(symbol, timeframe, lookback_days)
|
||||
|
||||
def _fetch_oanda(self, symbol: str, timeframe: str,
|
||||
lookback_days: int) -> Optional[pd.DataFrame]:
|
||||
"""Fetch forex data from OANDA API"""
|
||||
if not self.oanda:
|
||||
logger.debug(f"No OANDA broker configured, skipping {symbol}")
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
# Check what we already have
|
||||
latest_ts = self.store.get_latest_candle_timestamp(symbol, timeframe)
|
||||
if latest_ts:
|
||||
latest_dt = datetime.utcfromtimestamp(latest_ts / 1000)
|
||||
since_dt = latest_dt + timedelta(minutes=1)
|
||||
logger.debug(f"Cache has data until {latest_dt} for {symbol}/{timeframe}")
|
||||
else:
|
||||
since_dt = datetime.utcnow() - timedelta(days=lookback_days)
|
||||
|
||||
end_dt = datetime.utcnow()
|
||||
if since_dt >= end_dt - timedelta(minutes=5):
|
||||
logger.debug(f"Cache is up to date for {symbol}/{timeframe}")
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
try:
|
||||
bars = self.oanda.fetch_bars_range(
|
||||
symbol, timeframe=timeframe, start=since_dt, end=end_dt
|
||||
)
|
||||
if bars:
|
||||
self.store.store_candles(symbol, timeframe, bars)
|
||||
logger.info(f"Cached {len(bars)} candles for {symbol}/{timeframe} (OANDA)")
|
||||
|
||||
if timeframe == '4h':
|
||||
return self._resample_to_4h(symbol)
|
||||
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching OANDA candles for {symbol}: {e}")
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
def _fetch_yfinance(self, symbol: str, timeframe: str,
|
||||
lookback_days: int) -> Optional[pd.DataFrame]:
|
||||
"""Fetch stock data from yfinance"""
|
||||
tf_map = {
|
||||
'1m': '1m', '5m': '5m', '15m': '15m',
|
||||
'1h': '1h', '4h': '1h', '1d': '1d'
|
||||
}
|
||||
yf_interval = tf_map.get(timeframe, '1h')
|
||||
|
||||
max_lookback = {
|
||||
'1m': 7, '5m': 60, '15m': 60, '1h': 730, '1d': 3650
|
||||
}
|
||||
lookback_days = min(lookback_days, max_lookback.get(yf_interval, 90))
|
||||
|
||||
latest_ts = self.store.get_latest_candle_timestamp(symbol, timeframe)
|
||||
if latest_ts:
|
||||
latest_dt = datetime.utcfromtimestamp(latest_ts / 1000)
|
||||
since_dt = latest_dt + timedelta(minutes=1)
|
||||
logger.debug(f"Cache has data until {latest_dt} for {symbol}/{timeframe}")
|
||||
else:
|
||||
since_dt = datetime.utcnow() - timedelta(days=lookback_days)
|
||||
|
||||
try:
|
||||
end_dt = datetime.utcnow()
|
||||
if since_dt >= end_dt - timedelta(minutes=5):
|
||||
logger.debug(f"Cache is up to date for {symbol}/{timeframe}")
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(
|
||||
start=since_dt.strftime('%Y-%m-%d'),
|
||||
end=end_dt.strftime('%Y-%m-%d'),
|
||||
interval=yf_interval
|
||||
)
|
||||
|
||||
if hist.empty:
|
||||
logger.debug(f"No new data for {symbol}/{timeframe}")
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
candles = []
|
||||
for ts, row in hist.iterrows():
|
||||
candles.append({
|
||||
'timestamp': int(ts.timestamp() * 1000),
|
||||
'open': float(row['Open']),
|
||||
'high': float(row['High']),
|
||||
'low': float(row['Low']),
|
||||
'close': float(row['Close']),
|
||||
'volume': float(row['Volume'])
|
||||
})
|
||||
|
||||
if candles:
|
||||
self.store.store_candles(symbol, timeframe, candles)
|
||||
logger.info(f"Cached {len(candles)} candles for {symbol}/{timeframe}")
|
||||
|
||||
if timeframe == '4h':
|
||||
return self._resample_to_4h(symbol)
|
||||
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching candles for {symbol}: {e}")
|
||||
return self.store.get_candles(symbol, timeframe)
|
||||
|
||||
def _resample_to_4h(self, symbol: str) -> Optional[pd.DataFrame]:
|
||||
"""Resample 1h candles to 4h"""
|
||||
df = self.store.get_candles(symbol, '1h')
|
||||
if df is None or df.empty:
|
||||
return None
|
||||
|
||||
resampled = df.resample('4h').agg({
|
||||
'open': 'first',
|
||||
'high': 'max',
|
||||
'low': 'min',
|
||||
'close': 'last',
|
||||
'volume': 'sum'
|
||||
}).dropna()
|
||||
return resampled
|
||||
|
||||
def warm_cache(self, symbols: List[str], timeframes: List[str],
|
||||
lookback_days: int = 90):
|
||||
"""Pre-fetch historical data for all symbols/timeframes"""
|
||||
total = len(symbols) * len(timeframes)
|
||||
done = 0
|
||||
for symbol in symbols:
|
||||
for tf in timeframes:
|
||||
self.fetch_and_cache(symbol, tf, lookback_days)
|
||||
done += 1
|
||||
if done % 5 == 0:
|
||||
logger.info(f"Cache warmup: {done}/{total} complete")
|
||||
|
||||
logger.info(f"Cache warmup complete: {total} symbol/timeframe combinations")
|
||||
|
||||
def update_cache(self, symbols: List[str], timeframes: List[str]):
|
||||
"""Incremental update - fetch only new candles since last cached"""
|
||||
for symbol in symbols:
|
||||
for tf in timeframes:
|
||||
self.fetch_and_cache(symbol, tf, lookback_days=2)
|
||||
|
||||
def get_cached(self, symbol: str, timeframe: str,
|
||||
start: datetime = None, end: datetime = None) -> Optional[pd.DataFrame]:
|
||||
"""Get cached candles as DataFrame"""
|
||||
return self.store.get_candles(symbol, timeframe, start, end)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Feature Engineering Pipeline
|
||||
Computes technical indicators and normalizes features for ML input.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class FeatureEngine:
|
||||
"""Computes technical indicators and normalizes for ML input"""
|
||||
|
||||
FEATURE_NAMES = [
|
||||
# Trend (6)
|
||||
'sma_10', 'sma_20', 'sma_50',
|
||||
'ema_10', 'ema_20', 'ema_50',
|
||||
# MACD (3)
|
||||
'macd', 'macd_signal', 'macd_hist',
|
||||
# Momentum (4)
|
||||
'rsi_14', 'stoch_k', 'stoch_d', 'roc_10',
|
||||
# Volatility (5)
|
||||
'bb_upper', 'bb_middle', 'bb_lower', 'bb_width', 'atr_14',
|
||||
# Volume (3)
|
||||
'obv', 'volume_sma_20', 'volume_ratio',
|
||||
# Price action (5)
|
||||
'returns_1', 'returns_5', 'returns_10', 'returns_20',
|
||||
'high_low_range',
|
||||
# Relative position (3)
|
||||
'price_vs_sma20', 'price_vs_sma50', 'atr_pct',
|
||||
]
|
||||
|
||||
NUM_FEATURES = len(FEATURE_NAMES) # 29
|
||||
|
||||
def compute(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Compute all features from raw OHLCV DataFrame.
|
||||
Input df must have columns: open, high, low, close, volume
|
||||
Returns df with all feature columns appended, NaN rows dropped.
|
||||
"""
|
||||
df = df.copy()
|
||||
|
||||
c = df['close']
|
||||
h = df['high']
|
||||
l = df['low']
|
||||
o = df['open']
|
||||
v = df['volume']
|
||||
|
||||
# --- Trend indicators ---
|
||||
df['sma_10'] = c.rolling(10).mean()
|
||||
df['sma_20'] = c.rolling(20).mean()
|
||||
df['sma_50'] = c.rolling(50).mean()
|
||||
df['ema_10'] = c.ewm(span=10).mean()
|
||||
df['ema_20'] = c.ewm(span=20).mean()
|
||||
df['ema_50'] = c.ewm(span=50).mean()
|
||||
|
||||
# --- MACD ---
|
||||
ema12 = c.ewm(span=12).mean()
|
||||
ema26 = c.ewm(span=26).mean()
|
||||
df['macd'] = ema12 - ema26
|
||||
df['macd_signal'] = df['macd'].ewm(span=9).mean()
|
||||
df['macd_hist'] = df['macd'] - df['macd_signal']
|
||||
|
||||
# --- Momentum ---
|
||||
# RSI
|
||||
delta = c.diff()
|
||||
gain = delta.where(delta > 0, 0.0).rolling(14).mean()
|
||||
loss = (-delta.where(delta < 0, 0.0)).rolling(14).mean()
|
||||
rs = gain / loss.replace(0, np.nan)
|
||||
df['rsi_14'] = 100 - (100 / (1 + rs))
|
||||
|
||||
# Stochastic
|
||||
low14 = l.rolling(14).min()
|
||||
high14 = h.rolling(14).max()
|
||||
df['stoch_k'] = 100 * (c - low14) / (high14 - low14).replace(0, np.nan)
|
||||
df['stoch_d'] = df['stoch_k'].rolling(3).mean()
|
||||
|
||||
# Rate of change
|
||||
df['roc_10'] = c.pct_change(10) * 100
|
||||
|
||||
# --- Volatility ---
|
||||
# Bollinger Bands
|
||||
sma20 = c.rolling(20).mean()
|
||||
std20 = c.rolling(20).std()
|
||||
df['bb_upper'] = sma20 + 2 * std20
|
||||
df['bb_middle'] = sma20
|
||||
df['bb_lower'] = sma20 - 2 * std20
|
||||
df['bb_width'] = (df['bb_upper'] - df['bb_lower']) / df['bb_middle'].replace(0, np.nan)
|
||||
|
||||
# ATR
|
||||
tr = pd.concat([
|
||||
h - l,
|
||||
(h - c.shift(1)).abs(),
|
||||
(l - c.shift(1)).abs()
|
||||
], axis=1).max(axis=1)
|
||||
df['atr_14'] = tr.rolling(14).mean()
|
||||
|
||||
# --- Volume ---
|
||||
# OBV
|
||||
obv = pd.Series(0.0, index=df.index)
|
||||
obv_vals = [0.0]
|
||||
for i in range(1, len(df)):
|
||||
if c.iloc[i] > c.iloc[i-1]:
|
||||
obv_vals.append(obv_vals[-1] + v.iloc[i])
|
||||
elif c.iloc[i] < c.iloc[i-1]:
|
||||
obv_vals.append(obv_vals[-1] - v.iloc[i])
|
||||
else:
|
||||
obv_vals.append(obv_vals[-1])
|
||||
df['obv'] = obv_vals
|
||||
|
||||
df['volume_sma_20'] = v.rolling(20).mean()
|
||||
df['volume_ratio'] = v / df['volume_sma_20'].replace(0, np.nan)
|
||||
|
||||
# --- Price action ---
|
||||
df['returns_1'] = c.pct_change(1) * 100
|
||||
df['returns_5'] = c.pct_change(5) * 100
|
||||
df['returns_10'] = c.pct_change(10) * 100
|
||||
df['returns_20'] = c.pct_change(20) * 100
|
||||
df['high_low_range'] = (h - l) / c.replace(0, np.nan)
|
||||
|
||||
# --- Relative position ---
|
||||
df['price_vs_sma20'] = (c - df['sma_20']) / df['sma_20'].replace(0, np.nan) * 100
|
||||
df['price_vs_sma50'] = (c - df['sma_50']) / df['sma_50'].replace(0, np.nan) * 100
|
||||
df['atr_pct'] = df['atr_14'] / c.replace(0, np.nan) * 100
|
||||
|
||||
# Drop NaN rows from indicator warmup
|
||||
df.dropna(inplace=True)
|
||||
|
||||
return df
|
||||
|
||||
def normalize(self, df: pd.DataFrame, window: int = 200) -> pd.DataFrame:
|
||||
"""
|
||||
Z-score normalize feature columns using a rolling window.
|
||||
Avoids look-ahead bias by using only past data.
|
||||
"""
|
||||
result = df.copy()
|
||||
for col in self.FEATURE_NAMES:
|
||||
if col in result.columns:
|
||||
rolling_mean = result[col].rolling(window, min_periods=20).mean()
|
||||
rolling_std = result[col].rolling(window, min_periods=20).std()
|
||||
result[col] = (result[col] - rolling_mean) / rolling_std.replace(0, np.nan)
|
||||
|
||||
result.dropna(inplace=True)
|
||||
# Clip extreme values
|
||||
for col in self.FEATURE_NAMES:
|
||||
if col in result.columns:
|
||||
result[col] = result[col].clip(-3, 3)
|
||||
|
||||
return result
|
||||
|
||||
def get_state_vector(self, df: pd.DataFrame, index: int = -1) -> np.ndarray:
|
||||
"""
|
||||
Extract a single normalized state vector at a given index.
|
||||
Returns shape: (NUM_FEATURES,)
|
||||
"""
|
||||
if index < 0:
|
||||
index = len(df) + index
|
||||
|
||||
row = df.iloc[index]
|
||||
features = []
|
||||
for col in self.FEATURE_NAMES:
|
||||
if col in df.columns:
|
||||
val = row[col]
|
||||
features.append(0.0 if pd.isna(val) else float(val))
|
||||
else:
|
||||
features.append(0.0)
|
||||
|
||||
return np.array(features, dtype=np.float32)
|
||||
|
||||
def compute_and_normalize(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Compute features and normalize in one step"""
|
||||
featured = self.compute(df)
|
||||
if len(featured) < 20:
|
||||
return featured
|
||||
return self.normalize(featured)
|
||||
@@ -0,0 +1,504 @@
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"time": "2026-03-12T22:53:54Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50334,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T22:55:44Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50349,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T22:57:38Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50364,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T22:59:44Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50379,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:01:50Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50394,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:03:52Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50409,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:05:50Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50424,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:07:42Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50439,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:09:42Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50454,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:11:47Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 50469,
|
||||
"fitness": 23.8731
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:13:24Z",
|
||||
"type": "bot_started",
|
||||
"data": {
|
||||
"message": "BIGGFISH autonomous trader started"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:14:39Z",
|
||||
"type": "bot_started",
|
||||
"data": {
|
||||
"message": "BIGGFISH autonomous trader started"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:15:49Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "AUD_USD",
|
||||
"side": "buy",
|
||||
"amount": 27957,
|
||||
"entry_price": 0.70788
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:15:51Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "USD_CAD",
|
||||
"side": "buy",
|
||||
"amount": 14515,
|
||||
"entry_price": 1.36334
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:15:53Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "EUR_GBP",
|
||||
"side": "buy",
|
||||
"amount": 22932,
|
||||
"entry_price": 0.86296
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:15:54Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "USD_CHF",
|
||||
"side": "buy",
|
||||
"amount": 25186,
|
||||
"entry_price": 0.78568
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:15:56Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "NZD_USD",
|
||||
"side": "buy",
|
||||
"amount": 33817,
|
||||
"entry_price": 0.58514
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:16:49Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 15,
|
||||
"fitness": 1.7479
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:16:51Z",
|
||||
"type": "daily_report",
|
||||
"data": {
|
||||
"equity": 98929.1,
|
||||
"day_pnl": -1070.9,
|
||||
"trades_count": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:17:44Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "GBP_USD",
|
||||
"side": "buy",
|
||||
"amount": 14823,
|
||||
"entry_price": 1.3348
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:17:47Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "AUD_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.70784,
|
||||
"pnl": -0.56,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:18:12Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 30,
|
||||
"fitness": 1.8512
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:19:43Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "EUR_USD",
|
||||
"side": "buy",
|
||||
"amount": 17179,
|
||||
"entry_price": 1.1516
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:19:48Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "USD_JPY",
|
||||
"side": "buy",
|
||||
"amount": 124,
|
||||
"entry_price": 159.342
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:19:51Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "USD_CAD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 1.3636,
|
||||
"pnl": 1.89,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:19:56Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "NZD_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.585,
|
||||
"pnl": -2.37,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:20:16Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 45,
|
||||
"fitness": 1.8512
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:21:44Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "GBP_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 1.33478,
|
||||
"pnl": -0.3,
|
||||
"pnl_pct": -0.0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:21:47Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "USD_CAD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 1.36348,
|
||||
"pnl": 0.51,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:21:49Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "EUR_GBP",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.86288,
|
||||
"pnl": -0.92,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:22:12Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 60,
|
||||
"fitness": 1.8512
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:23:49Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "USD_CAD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 1.36355,
|
||||
"pnl": 0.76,
|
||||
"pnl_pct": 0.02,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:23:53Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "USD_CHF",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.78586,
|
||||
"pnl": 2.27,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:24:15Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 75,
|
||||
"fitness": 1.8512
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:25:45Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "GBP_USD",
|
||||
"side": "buy",
|
||||
"amount": 14822,
|
||||
"entry_price": 1.33468
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:25:51Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "EUR_GBP",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.8628,
|
||||
"pnl": -0.92,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:26:10Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 90,
|
||||
"fitness": 1.8512
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:27:45Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "USD_JPY",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 159.35,
|
||||
"pnl": 0.99,
|
||||
"pnl_pct": 0.01,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:28:10Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 105,
|
||||
"fitness": 1.8512
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:29:45Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "GBP_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 1.33488,
|
||||
"pnl": 2.96,
|
||||
"pnl_pct": 0.01,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:29:46Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "USD_JPY",
|
||||
"side": "buy",
|
||||
"amount": 124,
|
||||
"entry_price": 159.326
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:29:48Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "AUD_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.70756,
|
||||
"pnl": -2.24,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:29:52Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "USD_CHF",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.78578,
|
||||
"pnl": 1.26,
|
||||
"pnl_pct": 0.01,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:29:54Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "NZD_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 0.58492,
|
||||
"pnl": -3.72,
|
||||
"pnl_pct": -0.04,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:30:25Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 120,
|
||||
"fitness": 1.8615
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:31:43Z",
|
||||
"type": "trade_close",
|
||||
"data": {
|
||||
"symbol": "EUR_USD",
|
||||
"side": "sell",
|
||||
"entry_price": null,
|
||||
"exit_price": 1.15166,
|
||||
"pnl": 0.52,
|
||||
"pnl_pct": 0,
|
||||
"exit_reason": "signal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:31:45Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "GBP_USD",
|
||||
"side": "buy",
|
||||
"amount": 14821,
|
||||
"entry_price": 1.33481
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:31:48Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "USD_CAD",
|
||||
"side": "buy",
|
||||
"amount": 14506,
|
||||
"entry_price": 1.36368
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:31:51Z",
|
||||
"type": "trade_open",
|
||||
"data": {
|
||||
"symbol": "USD_CHF",
|
||||
"side": "buy",
|
||||
"amount": 25174,
|
||||
"entry_price": 0.78579
|
||||
}
|
||||
},
|
||||
{
|
||||
"time": "2026-03-12T23:32:10Z",
|
||||
"type": "ga_milestone",
|
||||
"data": {
|
||||
"generation": 135,
|
||||
"fitness": 1.8615
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"updated_at": "2026-03-12T23:32:11Z",
|
||||
"uptime_hours": 0.3,
|
||||
"markets": {
|
||||
"stocks": "CLOSED",
|
||||
"forex": "OPEN"
|
||||
},
|
||||
"portfolio": {
|
||||
"equity": 98905.3095,
|
||||
"cash": 98924.0431,
|
||||
"buying_power": 96190.7269,
|
||||
"portfolio_value": 98905.3095,
|
||||
"long_market_value": -18.733599999992293,
|
||||
"day_pnl": -1094.578999999998,
|
||||
"day_pnl_pct": -1.094578999999998
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
"symbol": "NZD_USD",
|
||||
"qty": 16,
|
||||
"entry_price": 0.58526,
|
||||
"current_price": 0.58482875,
|
||||
"unrealized_pnl": -0.0069
|
||||
},
|
||||
{
|
||||
"symbol": "AUD_USD",
|
||||
"qty": 7018,
|
||||
"entry_price": 0.70794,
|
||||
"current_price": 0.7074600056996295,
|
||||
"unrealized_pnl": -3.3686
|
||||
},
|
||||
{
|
||||
"symbol": "USD_JPY",
|
||||
"qty": 140,
|
||||
"entry_price": 159.338,
|
||||
"current_price": 159.33775642857142,
|
||||
"unrealized_pnl": -0.0341
|
||||
},
|
||||
{
|
||||
"symbol": "USD_CHF",
|
||||
"qty": 25181,
|
||||
"entry_price": 0.78588,
|
||||
"current_price": 0.7855858536197927,
|
||||
"unrealized_pnl": -7.4069
|
||||
},
|
||||
{
|
||||
"symbol": "GBP_USD",
|
||||
"qty": 14839,
|
||||
"entry_price": 1.3349,
|
||||
"current_price": 1.3347701260192735,
|
||||
"unrealized_pnl": -1.9272
|
||||
},
|
||||
{
|
||||
"symbol": "USD_CAD",
|
||||
"qty": 14526,
|
||||
"entry_price": 1.36378,
|
||||
"current_price": 1.3635518642434257,
|
||||
"unrealized_pnl": -3.3139
|
||||
},
|
||||
{
|
||||
"symbol": "EUR_GBP",
|
||||
"qty": 5748,
|
||||
"entry_price": 0.86301,
|
||||
"current_price": 0.8626343562978428,
|
||||
"unrealized_pnl": -2.1592
|
||||
},
|
||||
{
|
||||
"symbol": "EUR_USD",
|
||||
"qty": 8614,
|
||||
"entry_price": 1.15168,
|
||||
"current_price": 1.1516200046436034,
|
||||
"unrealized_pnl": -0.5168
|
||||
}
|
||||
],
|
||||
"learning": {
|
||||
"ga_generation": 135,
|
||||
"ga_best_fitness": 1.8615,
|
||||
"rl_epsilon": 0.7471,
|
||||
"rl_experiences": 710,
|
||||
"rl_loss": 0.000668
|
||||
},
|
||||
"today_summary": {
|
||||
"trades_count": 22,
|
||||
"wins": 0,
|
||||
"losses": 15,
|
||||
"total_pnl": -239.08
|
||||
},
|
||||
"config": {
|
||||
"stock_symbols": [
|
||||
"SOUN",
|
||||
"MARA",
|
||||
"RIOT",
|
||||
"BBAI",
|
||||
"PLTR",
|
||||
"HOOD",
|
||||
"SOFI",
|
||||
"COIN",
|
||||
"RBLX",
|
||||
"DKNG"
|
||||
],
|
||||
"forex_symbols": [
|
||||
"EUR_USD",
|
||||
"GBP_USD",
|
||||
"USD_JPY",
|
||||
"AUD_USD",
|
||||
"USD_CAD",
|
||||
"EUR_GBP",
|
||||
"USD_CHF",
|
||||
"NZD_USD"
|
||||
],
|
||||
"initial_capital": 100000,
|
||||
"target_capital": 1000000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
SQLite Data Access Layer for BIGGFISH
|
||||
Persists trades, candles, strategy performance, model checkpoints, and system state.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from loguru import logger
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class DataStore:
|
||||
"""SQLite data access layer for all BIGGFISH persistence"""
|
||||
|
||||
def __init__(self, db_path: str = "data/biggfish.db"):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
self._conn = sqlite3.connect(str(self.db_path), timeout=30)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA busy_timeout=5000")
|
||||
return self._conn
|
||||
|
||||
def initialize(self):
|
||||
"""Create all tables if they don't exist"""
|
||||
c = self.conn
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL NOT NULL,
|
||||
UNIQUE(symbol, timeframe, timestamp)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_candles_lookup
|
||||
ON candles(symbol, timeframe, timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
entry_price REAL NOT NULL,
|
||||
exit_price REAL,
|
||||
entry_time TEXT NOT NULL,
|
||||
exit_time TEXT,
|
||||
strategy_id TEXT,
|
||||
stop_loss REAL,
|
||||
take_profit REAL,
|
||||
pnl REAL,
|
||||
pnl_pct REAL,
|
||||
fees REAL DEFAULT 0,
|
||||
order_id TEXT,
|
||||
status TEXT DEFAULT 'open',
|
||||
metadata TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_strategy ON trades(strategy_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS strategy_performance (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
strategy_id TEXT NOT NULL,
|
||||
params TEXT NOT NULL,
|
||||
backtest_start TEXT,
|
||||
backtest_end TEXT,
|
||||
total_trades INTEGER,
|
||||
win_rate REAL,
|
||||
profit_factor REAL,
|
||||
sharpe_ratio REAL,
|
||||
sortino_ratio REAL,
|
||||
max_drawdown REAL,
|
||||
total_return REAL,
|
||||
avg_trade_pnl REAL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'backtest'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_strat_perf
|
||||
ON strategy_performance(strategy_id, recorded_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_checkpoints (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_name TEXT NOT NULL,
|
||||
epoch INTEGER NOT NULL,
|
||||
state_dict BLOB NOT NULL,
|
||||
metrics TEXT,
|
||||
saved_at TEXT NOT NULL,
|
||||
UNIQUE(model_name, epoch)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS evolution_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
generation INTEGER NOT NULL,
|
||||
population TEXT NOT NULL,
|
||||
best_fitness REAL NOT NULL,
|
||||
avg_fitness REAL,
|
||||
recorded_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
c.commit()
|
||||
logger.info(f"Database initialized at {self.db_path}")
|
||||
|
||||
# --- Candle cache ---
|
||||
|
||||
def store_candles(self, symbol: str, timeframe: str, candles: List[Dict]):
|
||||
"""Store OHLCV candles (upsert)"""
|
||||
if not candles:
|
||||
return
|
||||
c = self.conn
|
||||
c.executemany(
|
||||
"""INSERT OR REPLACE INTO candles
|
||||
(symbol, timeframe, timestamp, open, high, low, close, volume)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
[(symbol, timeframe, int(row['timestamp']),
|
||||
row['open'], row['high'], row['low'], row['close'], row['volume'])
|
||||
for row in candles]
|
||||
)
|
||||
c.commit()
|
||||
|
||||
def get_candles(self, symbol: str, timeframe: str,
|
||||
start: datetime = None, end: datetime = None) -> Optional[pd.DataFrame]:
|
||||
"""Return cached candles as DataFrame"""
|
||||
query = "SELECT timestamp, open, high, low, close, volume FROM candles WHERE symbol=? AND timeframe=?"
|
||||
params = [symbol, timeframe]
|
||||
|
||||
if start:
|
||||
query += " AND timestamp >= ?"
|
||||
params.append(int(start.timestamp() * 1000))
|
||||
if end:
|
||||
query += " AND timestamp <= ?"
|
||||
params.append(int(end.timestamp() * 1000))
|
||||
|
||||
query += " ORDER BY timestamp ASC"
|
||||
|
||||
df = pd.read_sql_query(query, self.conn, params=params)
|
||||
if df.empty:
|
||||
return None
|
||||
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
|
||||
df.set_index('timestamp', inplace=True)
|
||||
return df
|
||||
|
||||
def get_latest_candle_timestamp(self, symbol: str, timeframe: str) -> Optional[int]:
|
||||
"""Get the most recent cached candle timestamp (epoch ms)"""
|
||||
row = self.conn.execute(
|
||||
"SELECT MAX(timestamp) as ts FROM candles WHERE symbol=? AND timeframe=?",
|
||||
(symbol, timeframe)
|
||||
).fetchone()
|
||||
return row['ts'] if row and row['ts'] else None
|
||||
|
||||
def get_candle_count(self, symbol: str, timeframe: str) -> int:
|
||||
"""Get number of cached candles"""
|
||||
row = self.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM candles WHERE symbol=? AND timeframe=?",
|
||||
(symbol, timeframe)
|
||||
).fetchone()
|
||||
return row['cnt'] if row else 0
|
||||
|
||||
# --- Trades ---
|
||||
|
||||
def record_trade(self, trade: Dict) -> int:
|
||||
"""Record a trade, return its ID"""
|
||||
c = self.conn
|
||||
cursor = c.execute(
|
||||
"""INSERT INTO trades
|
||||
(symbol, side, amount, entry_price, exit_price, entry_time, exit_time,
|
||||
strategy_id, stop_loss, take_profit, pnl, pnl_pct, fees, order_id, status, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(trade['symbol'], trade['side'], trade['amount'], trade['entry_price'],
|
||||
trade.get('exit_price'), trade['entry_time'], trade.get('exit_time'),
|
||||
trade.get('strategy_id'), trade.get('stop_loss'), trade.get('take_profit'),
|
||||
trade.get('pnl'), trade.get('pnl_pct'), trade.get('fees', 0),
|
||||
trade.get('order_id'), trade.get('status', 'open'),
|
||||
json.dumps(trade.get('metadata', {})))
|
||||
)
|
||||
c.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_trades(self, symbol: str = None, status: str = None,
|
||||
start: datetime = None, limit: int = 100) -> List[Dict]:
|
||||
"""Get trades with optional filters"""
|
||||
query = "SELECT * FROM trades WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if symbol:
|
||||
query += " AND symbol=?"
|
||||
params.append(symbol)
|
||||
if status:
|
||||
query += " AND status=?"
|
||||
params.append(status)
|
||||
if start:
|
||||
query += " AND entry_time >= ?"
|
||||
params.append(start.isoformat())
|
||||
|
||||
query += " ORDER BY entry_time DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = self.conn.execute(query, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_open_positions(self) -> List[Dict]:
|
||||
"""Get all open trades"""
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM trades WHERE status='open' ORDER BY entry_time DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def close_position(self, trade_id: int, exit_price: float,
|
||||
exit_time: datetime, fees: float = 0):
|
||||
"""Close a trade position"""
|
||||
trade = self.conn.execute(
|
||||
"SELECT * FROM trades WHERE id=?", (trade_id,)
|
||||
).fetchone()
|
||||
|
||||
if not trade:
|
||||
return
|
||||
|
||||
trade = dict(trade)
|
||||
if trade['side'] == 'buy':
|
||||
pnl = (exit_price - trade['entry_price']) * trade['amount'] - fees
|
||||
pnl_pct = ((exit_price - trade['entry_price']) / trade['entry_price']) * 100
|
||||
else:
|
||||
pnl = (trade['entry_price'] - exit_price) * trade['amount'] - fees
|
||||
pnl_pct = ((trade['entry_price'] - exit_price) / trade['entry_price']) * 100
|
||||
|
||||
self.conn.execute(
|
||||
"""UPDATE trades SET exit_price=?, exit_time=?, pnl=?, pnl_pct=?,
|
||||
fees=?, status='closed' WHERE id=?""",
|
||||
(exit_price, exit_time.isoformat(), round(pnl, 4),
|
||||
round(pnl_pct, 4), fees, trade_id)
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
# --- Strategy performance ---
|
||||
|
||||
def record_strategy_result(self, strategy_id: str, params: Dict, metrics: Dict):
|
||||
"""Record a backtest or live strategy result"""
|
||||
self.conn.execute(
|
||||
"""INSERT INTO strategy_performance
|
||||
(strategy_id, params, backtest_start, backtest_end, total_trades,
|
||||
win_rate, profit_factor, sharpe_ratio, sortino_ratio, max_drawdown,
|
||||
total_return, avg_trade_pnl, recorded_at, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(strategy_id, json.dumps(params),
|
||||
metrics.get('backtest_start'), metrics.get('backtest_end'),
|
||||
metrics.get('total_trades', 0), metrics.get('win_rate', 0),
|
||||
metrics.get('profit_factor', 0), metrics.get('sharpe_ratio', 0),
|
||||
metrics.get('sortino_ratio', 0), metrics.get('max_drawdown', 0),
|
||||
metrics.get('total_return', 0), metrics.get('avg_trade_pnl', 0),
|
||||
datetime.now().isoformat(), metrics.get('source', 'backtest'))
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_best_strategies(self, metric: str = "sharpe_ratio",
|
||||
limit: int = 10) -> List[Dict]:
|
||||
"""Get top performing strategies"""
|
||||
rows = self.conn.execute(
|
||||
f"SELECT * FROM strategy_performance ORDER BY {metric} DESC LIMIT ?",
|
||||
(limit,)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# --- Model checkpoints ---
|
||||
|
||||
def save_model_checkpoint(self, model_name: str, epoch: int,
|
||||
state_dict_bytes: bytes, metrics: Dict = None):
|
||||
"""Save a model checkpoint"""
|
||||
self.conn.execute(
|
||||
"""INSERT OR REPLACE INTO model_checkpoints
|
||||
(model_name, epoch, state_dict, metrics, saved_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(model_name, epoch, state_dict_bytes,
|
||||
json.dumps(metrics or {}), datetime.now().isoformat())
|
||||
)
|
||||
self.conn.commit()
|
||||
logger.debug(f"Saved checkpoint: {model_name} epoch {epoch}")
|
||||
|
||||
def load_latest_checkpoint(self, model_name: str) -> Optional[Dict]:
|
||||
"""Load the most recent model checkpoint"""
|
||||
row = self.conn.execute(
|
||||
"""SELECT * FROM model_checkpoints
|
||||
WHERE model_name=? ORDER BY epoch DESC LIMIT 1""",
|
||||
(model_name,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
# --- Evolution history ---
|
||||
|
||||
def record_generation(self, generation: int, population: List[Dict],
|
||||
best_fitness: float, avg_fitness: float = 0):
|
||||
"""Record a GA generation"""
|
||||
self.conn.execute(
|
||||
"""INSERT INTO evolution_history
|
||||
(generation, population, best_fitness, avg_fitness, recorded_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(generation, json.dumps(population), best_fitness,
|
||||
avg_fitness, datetime.now().isoformat())
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_latest_generation(self) -> Optional[Dict]:
|
||||
"""Load the most recent GA generation"""
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM evolution_history ORDER BY generation DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row:
|
||||
result = dict(row)
|
||||
result['population'] = json.loads(result['population'])
|
||||
return result
|
||||
return None
|
||||
|
||||
# --- System state ---
|
||||
|
||||
def save_state(self, key: str, value: str):
|
||||
"""Save a system state key-value pair"""
|
||||
self.conn.execute(
|
||||
"""INSERT OR REPLACE INTO system_state (key, value, updated_at)
|
||||
VALUES (?, ?, ?)""",
|
||||
(key, value, datetime.now().isoformat())
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def load_state(self, key: str) -> Optional[str]:
|
||||
"""Load a system state value"""
|
||||
row = self.conn.execute(
|
||||
"SELECT value FROM system_state WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
return row['value'] if row else None
|
||||
|
||||
# --- Portfolio snapshots ---
|
||||
|
||||
def save_portfolio_snapshot(self, portfolio_value: float, date: str = None):
|
||||
"""Save end-of-day portfolio snapshot"""
|
||||
if date is None:
|
||||
date = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
try:
|
||||
self.conn.execute(
|
||||
"""INSERT OR REPLACE INTO portfolio_snapshots
|
||||
(date, portfolio_value, recorded_at)
|
||||
VALUES (?, ?, ?)""",
|
||||
(date, portfolio_value, datetime.utcnow().isoformat())
|
||||
)
|
||||
self.conn.commit()
|
||||
logger.debug(f"Saved portfolio snapshot: {date} = ${portfolio_value:.2f}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving portfolio snapshot: {e}")
|
||||
|
||||
def get_last_portfolio_snapshot(self, days_ago: int = 1) -> Optional[float]:
|
||||
"""Get portfolio value from N days ago"""
|
||||
try:
|
||||
target_date = (datetime.utcnow() - timedelta(days=days_ago)).strftime("%Y-%m-%d")
|
||||
row = self.conn.execute(
|
||||
"""SELECT portfolio_value FROM portfolio_snapshots
|
||||
WHERE date <= ? ORDER BY date DESC LIMIT 1""",
|
||||
(target_date,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return float(row[0])
|
||||
# Fallback to None if no snapshot exists
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching portfolio snapshot: {e}")
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
"""Close the database connection"""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
@@ -0,0 +1,778 @@
|
||||
"""
|
||||
BIGGFISH Autonomous Self-Learning Trading Bot
|
||||
24/7 entry point - runs forever, learns continuously, trades autonomously.
|
||||
|
||||
Usage:
|
||||
python -m src.main_auto
|
||||
python -m src.main_auto --config config/auto_config.json
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import signal as signal_module
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from loguru import logger
|
||||
import numpy as np
|
||||
|
||||
# Setup logging
|
||||
log_path = Path(__file__).parent.parent / "logs"
|
||||
log_path.mkdir(exist_ok=True)
|
||||
logger.add(
|
||||
log_path / "biggfish_auto_{time}.log",
|
||||
rotation="1 day",
|
||||
retention="30 days",
|
||||
level="INFO"
|
||||
)
|
||||
|
||||
from trading.broker import AlpacaBroker
|
||||
from data.store import DataStore
|
||||
from data.candle_cache import CandleCache
|
||||
try:
|
||||
from trading.oanda_broker import OandaBroker
|
||||
except ImportError:
|
||||
OandaBroker = None
|
||||
from data.features import FeatureEngine
|
||||
from backtest.engine import BacktestEngine
|
||||
from backtest.metrics import compute_metrics
|
||||
from ml.rl_agent import RLAgent
|
||||
from ml.rl_environment import TradingEnvironment
|
||||
from ml.genetic import GeneticEvolver, StrategyGenome
|
||||
from strategies.auto_strategy import genome_to_strategy, evaluate_genome_signal
|
||||
from trading.executor import TradingExecutor
|
||||
from core.safety import SafetyManager
|
||||
from reporting.telegram_reporter import TelegramReporter
|
||||
from reporting.krystie_bridge import KrystieBridge
|
||||
|
||||
|
||||
class BiggFishAuto:
|
||||
"""
|
||||
24/7 Autonomous self-learning trading bot.
|
||||
Coordinates trading, backtesting, RL training, and GA evolution.
|
||||
"""
|
||||
|
||||
def __init__(self, config_path: str = "config/auto_config.json"):
|
||||
logger.info("Initializing BIGGFISH Autonomous Trader...")
|
||||
|
||||
# Load config
|
||||
with open(config_path) as f:
|
||||
self.config = json.load(f)
|
||||
|
||||
# Initialize components
|
||||
self.store = DataStore(self.config['database']['path'])
|
||||
self.store.initialize()
|
||||
|
||||
self.broker = AlpacaBroker(self.config['alpaca'])
|
||||
self.feature_engine = FeatureEngine()
|
||||
|
||||
# OANDA broker for forex (optional)
|
||||
self.oanda_broker = None
|
||||
oanda_config = self.config.get('oanda')
|
||||
if oanda_config and oanda_config.get('api_token') and OandaBroker:
|
||||
try:
|
||||
self.oanda_broker = OandaBroker(oanda_config)
|
||||
logger.info("OANDA forex broker connected")
|
||||
except Exception as e:
|
||||
logger.warning(f"OANDA connection failed (forex disabled): {e}")
|
||||
|
||||
self.candle_cache = CandleCache(self.store, oanda_broker=self.oanda_broker)
|
||||
|
||||
self.backtest_engine = BacktestEngine(
|
||||
initial_capital=self.config['backtest']['initial_capital'],
|
||||
commission_rate=self.config['trading'].get('commission_rate', 0.001)
|
||||
)
|
||||
|
||||
self.safety = SafetyManager(self.config['safety'], self.store)
|
||||
|
||||
# Executors per broker
|
||||
self.executor = TradingExecutor(
|
||||
self.broker, self.store, self.safety, self.config['trading']
|
||||
)
|
||||
self.forex_executor = None
|
||||
if self.oanda_broker:
|
||||
self.forex_executor = TradingExecutor(
|
||||
self.oanda_broker, self.store, self.safety, self.config['trading']
|
||||
)
|
||||
|
||||
# RL Agent
|
||||
env_temp = TradingEnvironment(self.feature_engine,
|
||||
initial_capital=self.config['trading']['initial_capital'])
|
||||
self.rl_agent = RLAgent(
|
||||
state_dim=env_temp.state_dim,
|
||||
action_dim=TradingEnvironment.NUM_ACTIONS,
|
||||
config=self.config['rl']
|
||||
)
|
||||
|
||||
# GA Evolver
|
||||
self.ga_evolver = GeneticEvolver(
|
||||
self.config['ga'], self.backtest_engine, self.store
|
||||
)
|
||||
|
||||
# Telegram daily reporter
|
||||
tg_config = self.config.get('telegram', {})
|
||||
if tg_config.get('enabled') and tg_config.get('bot_token') and tg_config.get('chat_id'):
|
||||
self.telegram = TelegramReporter(tg_config['bot_token'], tg_config['chat_id'])
|
||||
logger.info("Telegram reporting enabled")
|
||||
else:
|
||||
self.telegram = None
|
||||
logger.info("Telegram reporting disabled (no config)")
|
||||
|
||||
# Krystie bridge (writes status/events to JSON for Krystie to read)
|
||||
self.krystie = KrystieBridge(self.config['database']['path'].rsplit('/', 1)[0] or 'data')
|
||||
|
||||
# State
|
||||
self.running = False
|
||||
self.start_time = None
|
||||
self.cycle_count = 0
|
||||
self.last_backtest = datetime.min
|
||||
self.last_rl_train = datetime.min
|
||||
self.last_ga_evolve = datetime.min
|
||||
self.last_dashboard = datetime.min
|
||||
self.last_daily_report = datetime.min
|
||||
self.recent_trades = [] # Last 10 trades for dashboard
|
||||
|
||||
logger.info("BIGGFISH Autonomous Trader initialized")
|
||||
|
||||
def start(self):
|
||||
"""Start the autonomous trading system"""
|
||||
self.running = True
|
||||
self.start_time = datetime.utcnow()
|
||||
|
||||
# Register shutdown handler
|
||||
signal_module.signal(signal_module.SIGINT, self._signal_handler)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(" BIGGFISH AUTONOMOUS TRADER STARTING")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# 1. Load saved state
|
||||
self._load_state()
|
||||
|
||||
self.krystie.log_startup()
|
||||
|
||||
# 2. Warm candle cache
|
||||
self._warm_cache()
|
||||
|
||||
# 3. Initial GA population
|
||||
self.ga_evolver.initialize_population()
|
||||
if not self.ga_evolver.population:
|
||||
logger.info("Running initial GA evolution...")
|
||||
self._run_ga_evolution()
|
||||
|
||||
# 4. Initial RL training if no checkpoint
|
||||
if self.rl_agent.steps == 0:
|
||||
logger.info("Running initial RL training...")
|
||||
self._run_rl_training()
|
||||
|
||||
# 5. Main loop
|
||||
logger.info("Entering main loop...")
|
||||
self._print_dashboard()
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
cycle_start = datetime.utcnow()
|
||||
|
||||
# Trading cycle (run if any market is open)
|
||||
any_market_open = self.broker.is_market_open()
|
||||
if self.oanda_broker:
|
||||
any_market_open = any_market_open or self.oanda_broker.is_market_open()
|
||||
|
||||
if any_market_open:
|
||||
self._trading_cycle()
|
||||
else:
|
||||
logger.debug("All markets closed - running learning tasks")
|
||||
|
||||
# Periodic tasks (run regardless of market hours)
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Cache update (every 5 min)
|
||||
cache_interval = self.config['cache']['update_interval_seconds']
|
||||
if (now - self.last_backtest).total_seconds() > cache_interval:
|
||||
self._update_cache()
|
||||
|
||||
# Backtest (every 30 min)
|
||||
bt_interval = self.config['backtest']['interval_seconds']
|
||||
if (now - self.last_backtest).total_seconds() > bt_interval:
|
||||
self._run_backtest()
|
||||
self.last_backtest = now
|
||||
|
||||
# RL training (every 2 hours)
|
||||
rl_interval = self.config['rl']['train_interval_hours'] * 3600
|
||||
if (now - self.last_rl_train).total_seconds() > rl_interval:
|
||||
self._run_rl_training()
|
||||
self.last_rl_train = now
|
||||
|
||||
# GA evolution (every 6 hours)
|
||||
ga_interval = self.config['ga']['evolution_interval_hours'] * 3600
|
||||
if (now - self.last_ga_evolve).total_seconds() > ga_interval:
|
||||
self._run_ga_evolution()
|
||||
self.last_ga_evolve = now
|
||||
|
||||
# Daily Telegram report (once per day, around market close ~21:00 UTC)
|
||||
if self.telegram and self._should_send_daily_report(now):
|
||||
self._send_daily_telegram_report()
|
||||
self.last_daily_report = now
|
||||
|
||||
# Dashboard (every minute)
|
||||
dash_interval = self.config['reporting']['dashboard_interval_seconds']
|
||||
if (now - self.last_dashboard).total_seconds() > dash_interval:
|
||||
self._print_dashboard()
|
||||
self.last_dashboard = now
|
||||
|
||||
# Sleep until next cycle
|
||||
elapsed = (datetime.utcnow() - cycle_start).total_seconds()
|
||||
sleep_time = max(1, self.config['trading']['cycle_interval_seconds'] - elapsed)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self._shutdown()
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Main loop error: {e}", exc_info=True)
|
||||
time.sleep(30)
|
||||
|
||||
def _is_forex(self, symbol: str) -> bool:
|
||||
"""Check if symbol is a forex pair (OANDA format: XXX_YYY)"""
|
||||
return '_' in symbol and len(symbol) == 7
|
||||
|
||||
def _get_broker(self, symbol: str):
|
||||
"""Get the appropriate broker for a symbol"""
|
||||
if self._is_forex(symbol) and self.oanda_broker:
|
||||
return self.oanda_broker
|
||||
return self.broker
|
||||
|
||||
def _get_executor(self, symbol: str):
|
||||
"""Get the appropriate executor for a symbol"""
|
||||
if self._is_forex(symbol) and self.forex_executor:
|
||||
return self.forex_executor
|
||||
return self.executor
|
||||
|
||||
def _trading_cycle(self):
|
||||
"""Run one trading cycle across all markets"""
|
||||
self.cycle_count += 1
|
||||
all_symbols = self._get_tradeable_symbols()
|
||||
|
||||
# Check exits first
|
||||
try:
|
||||
current_prices = {}
|
||||
for symbol in all_symbols:
|
||||
broker = self._get_broker(symbol)
|
||||
price = broker.get_latest_price(symbol)
|
||||
if price:
|
||||
current_prices[symbol] = price
|
||||
|
||||
# Check exits per executor
|
||||
stock_prices = {s: p for s, p in current_prices.items() if not self._is_forex(s)}
|
||||
forex_prices = {s: p for s, p in current_prices.items() if self._is_forex(s)}
|
||||
|
||||
if stock_prices:
|
||||
closed = self.executor.check_exits(stock_prices)
|
||||
for trade in closed:
|
||||
logger.info(f"CLOSED {trade['symbol']}: ${trade['pnl']:+.2f} "
|
||||
f"({trade['pnl_pct']:+.1f}%) [{trade['exit_reason']}]")
|
||||
self.recent_trades.append(trade)
|
||||
self.krystie.log_trade(trade)
|
||||
|
||||
if forex_prices and self.forex_executor:
|
||||
closed = self.forex_executor.check_exits(forex_prices)
|
||||
for trade in closed:
|
||||
logger.info(f"CLOSED {trade['symbol']}: ${trade['pnl']:+.2f} "
|
||||
f"({trade['pnl_pct']:+.1f}%) [{trade['exit_reason']}]")
|
||||
self.recent_trades.append(trade)
|
||||
self.krystie.log_trade(trade)
|
||||
except Exception as e:
|
||||
logger.error(f"Exit check error: {e}")
|
||||
|
||||
# Check safety
|
||||
if not self.safety.is_trading_allowed():
|
||||
logger.warning(f"Trading halted: {self.safety.halt_reason}")
|
||||
return
|
||||
|
||||
# Evaluate each symbol
|
||||
for symbol in all_symbols:
|
||||
try:
|
||||
self._trade_symbol(symbol)
|
||||
except Exception as e:
|
||||
logger.debug(f"Error trading {symbol}: {e}")
|
||||
|
||||
# Keep only last 20 trades
|
||||
self.recent_trades = self.recent_trades[-20:]
|
||||
|
||||
def _get_tradeable_symbols(self) -> list:
|
||||
"""Get symbols that can be traded right now"""
|
||||
stock_symbols = self.config['trading'].get('symbols', [])
|
||||
forex_symbols = self.config['trading'].get('forex_symbols', [])
|
||||
|
||||
tradeable = []
|
||||
|
||||
# Stocks: only if Alpaca market is open
|
||||
if self.broker.is_market_open():
|
||||
tradeable.extend(stock_symbols)
|
||||
|
||||
# Forex: if OANDA is connected and forex market is open (24/5)
|
||||
if self.oanda_broker and self.oanda_broker.is_market_open():
|
||||
tradeable.extend(forex_symbols)
|
||||
|
||||
return tradeable
|
||||
|
||||
def _trade_symbol(self, symbol: str):
|
||||
"""Evaluate and potentially trade a single symbol"""
|
||||
broker = self._get_broker(symbol)
|
||||
executor = self._get_executor(symbol)
|
||||
|
||||
# Get candle data
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
return
|
||||
|
||||
# Compute features
|
||||
features_df = self.feature_engine.compute_and_normalize(df)
|
||||
if features_df is None or len(features_df) < 10:
|
||||
return
|
||||
|
||||
# Get market state
|
||||
state = self.feature_engine.get_state_vector(features_df, -1)
|
||||
|
||||
# Get GA signal
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
ga_signal = {'signal': 'hold', 'confidence': 0.0}
|
||||
strategy_params = {}
|
||||
|
||||
if best_genome:
|
||||
raw_df = self.feature_engine.compute(df)
|
||||
if raw_df is not None and len(raw_df) > 0:
|
||||
ga_signal = evaluate_genome_signal(best_genome, raw_df, len(raw_df) - 1)
|
||||
strategy_params = {
|
||||
'stop_loss': ga_signal.get('stop_loss'),
|
||||
'take_profit': ga_signal.get('take_profit'),
|
||||
'position_pct': ga_signal.get('position_pct', 0.1),
|
||||
'strategy_id': f"ga_gen{best_genome.generation}",
|
||||
}
|
||||
|
||||
# Augment state with GA signal + portfolio
|
||||
portfolio_state = executor.get_portfolio_state()
|
||||
signal_dir = 1.0 if ga_signal['signal'] == 'buy' else (
|
||||
-1.0 if ga_signal['signal'] == 'sell' else 0.0)
|
||||
|
||||
augmented_state = np.concatenate([
|
||||
state,
|
||||
[portfolio_state.get('position_ratio', 0),
|
||||
portfolio_state.get('unrealized_pnl', 0),
|
||||
portfolio_state.get('time_in_position', 0)],
|
||||
[signal_dir, ga_signal.get('confidence', 0.0)],
|
||||
])
|
||||
|
||||
# RL agent decides
|
||||
action = self.rl_agent.select_action(augmented_state, live_mode=True)
|
||||
|
||||
if action == 0: # Hold
|
||||
return
|
||||
|
||||
# Get current price
|
||||
current_price = broker.get_latest_price(symbol)
|
||||
if not current_price:
|
||||
return
|
||||
|
||||
# Execute
|
||||
trade = executor.execute_signal(
|
||||
symbol, action, current_price, strategy_params
|
||||
)
|
||||
|
||||
if trade:
|
||||
action_name = TradingEnvironment.ACTION_NAMES[action]
|
||||
logger.info(f"TRADE: {action_name} {symbol} @ ${current_price:.4f}")
|
||||
self.recent_trades.append(trade)
|
||||
self.krystie.log_trade(trade)
|
||||
|
||||
def _all_symbols(self) -> list:
|
||||
"""Get all configured symbols (stocks + forex)"""
|
||||
symbols = list(self.config['trading'].get('symbols', []))
|
||||
symbols.extend(self.config['trading'].get('forex_symbols', []))
|
||||
return symbols
|
||||
|
||||
def _warm_cache(self):
|
||||
"""Warm the candle cache"""
|
||||
logger.info("Warming candle cache...")
|
||||
symbols = self._all_symbols()
|
||||
timeframes = self.config['cache'].get('timeframes', ['1h'])
|
||||
lookback = self.config['cache'].get('warmup_lookback_days', 90)
|
||||
|
||||
self.candle_cache.warm_cache(symbols, timeframes, lookback)
|
||||
logger.info("Cache warmup complete")
|
||||
|
||||
def _update_cache(self):
|
||||
"""Update candle cache incrementally"""
|
||||
symbols = self._all_symbols()
|
||||
timeframes = self.config['cache'].get('timeframes', ['1h'])
|
||||
self.candle_cache.update_cache(symbols, timeframes)
|
||||
|
||||
def _run_backtest(self):
|
||||
"""Run background backtest of current strategy"""
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
if not best_genome:
|
||||
return
|
||||
|
||||
strategy_fn = genome_to_strategy(best_genome)
|
||||
symbols = self._all_symbols()[:3] # Top 3 for speed
|
||||
|
||||
for symbol in symbols:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
|
||||
featured_df = self.feature_engine.compute(df)
|
||||
if featured_df is None or len(featured_df) < 50:
|
||||
continue
|
||||
|
||||
result = self.backtest_engine.run(
|
||||
strategy_fn, featured_df,
|
||||
params=best_genome.to_dict(), symbol=symbol
|
||||
)
|
||||
|
||||
if result.metrics.get('total_trades', 0) > 0:
|
||||
self.store.record_strategy_result(
|
||||
strategy_id=f"ga_gen{best_genome.generation}",
|
||||
params=best_genome.to_dict(),
|
||||
metrics=result.metrics
|
||||
)
|
||||
logger.info(f"Backtest {symbol}: Sharpe={result.metrics['sharpe_ratio']:.2f} "
|
||||
f"WR={result.metrics['win_rate']:.0f}% "
|
||||
f"Return={result.metrics['total_return']:.1f}%")
|
||||
|
||||
def _run_rl_training(self):
|
||||
"""Run RL batch training"""
|
||||
logger.info("Starting RL training...")
|
||||
env = TradingEnvironment(
|
||||
self.feature_engine,
|
||||
initial_capital=self.config['trading']['initial_capital']
|
||||
)
|
||||
|
||||
total_metrics = {'avg_loss': 0, 'episodes': 0}
|
||||
|
||||
for symbol in self._all_symbols()[:5]:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
if df is None or len(df) < 100:
|
||||
continue
|
||||
|
||||
# Create GA signal function for this data
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
ga_fn = None
|
||||
if best_genome:
|
||||
featured = self.feature_engine.compute(df)
|
||||
if featured is not None and len(featured) > 0:
|
||||
def make_ga_fn(genome, feat_df):
|
||||
def fn(step):
|
||||
if step < len(feat_df):
|
||||
return evaluate_genome_signal(genome, feat_df, step)
|
||||
return {'signal': 'hold', 'confidence': 0.0}
|
||||
return fn
|
||||
ga_fn = make_ga_fn(best_genome, featured)
|
||||
|
||||
metrics = self.rl_agent.train_on_episode(env, df, ga_signal_fn=ga_fn)
|
||||
total_metrics['avg_loss'] += metrics.get('avg_loss', 0)
|
||||
total_metrics['episodes'] += 1
|
||||
|
||||
# Save checkpoint
|
||||
self.rl_agent.save(self.store)
|
||||
|
||||
if total_metrics['episodes'] > 0:
|
||||
avg = total_metrics['avg_loss'] / total_metrics['episodes']
|
||||
logger.info(f"RL training complete: {total_metrics['episodes']} episodes, "
|
||||
f"avg_loss={avg:.6f}, epsilon={self.rl_agent.epsilon:.4f}")
|
||||
|
||||
def _run_ga_evolution(self):
|
||||
"""Run GA evolution cycle"""
|
||||
logger.info("Starting GA evolution...")
|
||||
|
||||
# Prepare candle data for fitness evaluation
|
||||
candles_data = {}
|
||||
symbols = self._all_symbols()[:5]
|
||||
|
||||
for symbol in symbols:
|
||||
df = self.candle_cache.get_cached(
|
||||
symbol, '1h',
|
||||
start=datetime.utcnow() - timedelta(days=30)
|
||||
)
|
||||
if df is not None and len(df) > 60:
|
||||
featured = self.feature_engine.compute(df)
|
||||
if featured is not None and len(featured) > 50:
|
||||
candles_data[symbol] = featured
|
||||
|
||||
if not candles_data:
|
||||
logger.warning("No data available for GA evolution")
|
||||
return
|
||||
|
||||
# Strategy function factory
|
||||
def strategy_fn_factory(genome):
|
||||
return genome_to_strategy(genome)
|
||||
|
||||
# Run evolution
|
||||
num_gens = self.config['ga'].get('generations_per_cycle', 10)
|
||||
best = self.ga_evolver.run_evolution_cycle(
|
||||
strategy_fn_factory, candles_data, num_generations=num_gens
|
||||
)
|
||||
|
||||
if best:
|
||||
logger.info(f"GA evolution complete: Gen {self.ga_evolver.generation}, "
|
||||
f"best fitness={best.fitness:.4f}")
|
||||
self.krystie.log_ga_milestone(self.ga_evolver.generation, best.fitness)
|
||||
|
||||
def _should_send_daily_report(self, now: datetime) -> bool:
|
||||
"""Check if it's time to send the daily report (once per day after 21:00 UTC)"""
|
||||
report_hour = self.config.get('telegram', {}).get('daily_report_hour', 21)
|
||||
if now.hour >= report_hour and (now - self.last_daily_report).total_seconds() > 20 * 3600:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _send_daily_telegram_report(self):
|
||||
"""Gather data and send the daily Telegram report"""
|
||||
try:
|
||||
portfolio = self.broker.get_portfolio()
|
||||
positions = self.broker.get_positions()
|
||||
|
||||
# Add OANDA positions if available
|
||||
if self.oanda_broker:
|
||||
try:
|
||||
oanda_positions = self.oanda_broker.get_positions()
|
||||
positions.extend(oanda_positions)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get today's trades from DB
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_trades = self.store.get_trades(start=today_start, limit=50)
|
||||
|
||||
# Learning stats
|
||||
rl_stats = self.rl_agent.get_stats()
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
learning_stats = {
|
||||
'rl': rl_stats,
|
||||
'ga': {
|
||||
'generation': self.ga_evolver.generation,
|
||||
'best_fitness': best_genome.fitness if best_genome else 0,
|
||||
},
|
||||
}
|
||||
|
||||
self.telegram.send_daily_report(
|
||||
portfolio, positions, today_trades, learning_stats,
|
||||
self.config['trading']
|
||||
)
|
||||
self.krystie.log_daily_report(
|
||||
equity=portfolio.get('equity', 0),
|
||||
day_pnl=portfolio.get('day_pnl', 0),
|
||||
trades_count=len(today_trades),
|
||||
)
|
||||
logger.info("Daily Telegram report sent")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send daily Telegram report: {e}")
|
||||
|
||||
def _print_dashboard(self):
|
||||
"""Print live console dashboard"""
|
||||
try:
|
||||
portfolio = self.broker.get_portfolio()
|
||||
positions = self.broker.get_positions()
|
||||
except Exception as e:
|
||||
logger.error(f"Dashboard error: {e}")
|
||||
return
|
||||
|
||||
equity = portfolio['equity']
|
||||
target = self.config['trading']['target_capital']
|
||||
initial = self.config['trading']['initial_capital']
|
||||
progress = (equity / target) * 100
|
||||
total_pnl = equity - initial
|
||||
total_pnl_pct = (total_pnl / initial) * 100
|
||||
|
||||
uptime = datetime.utcnow() - self.start_time if self.start_time else timedelta()
|
||||
hours = int(uptime.total_seconds() // 3600)
|
||||
minutes = int((uptime.total_seconds() % 3600) // 60)
|
||||
|
||||
best_genome = self.ga_evolver.get_best_genome()
|
||||
gen = self.ga_evolver.generation
|
||||
best_fit = best_genome.fitness if best_genome else 0
|
||||
|
||||
rl_stats = self.rl_agent.get_stats()
|
||||
safety_status = self.safety.get_status()
|
||||
|
||||
stock_status = "OPEN" if self.broker.is_market_open() else "CLOSED"
|
||||
forex_status = ""
|
||||
if self.oanda_broker:
|
||||
forex_status = " | FX: " + ("OPEN" if self.oanda_broker.is_market_open() else "CLOSED")
|
||||
|
||||
# Build dashboard
|
||||
lines = []
|
||||
lines.append("")
|
||||
lines.append("=" * 64)
|
||||
lines.append(f" BIGGFISH AUTONOMOUS TRADER | {hours}h {minutes}m | "
|
||||
f"Gen {gen} | Stocks: {stock_status}{forex_status}")
|
||||
lines.append("=" * 64)
|
||||
|
||||
# Progress bar
|
||||
bar_width = 30
|
||||
filled = int(bar_width * min(progress, 100) / 100)
|
||||
bar = "#" * filled + "-" * (bar_width - filled)
|
||||
lines.append(f" Portfolio: ${equity:.2f} / ${target} [{bar}] {progress:.1f}%")
|
||||
lines.append(f" Day P&L: ${portfolio['day_pnl']:+.2f} ({portfolio['day_pnl_pct']:+.1f}%)")
|
||||
lines.append(f" Total P&L: ${total_pnl:+.2f} ({total_pnl_pct:+.1f}%)")
|
||||
|
||||
# Positions
|
||||
lines.append("-" * 64)
|
||||
if positions:
|
||||
lines.append(f" Active Positions ({len(positions)}):")
|
||||
for p in positions[:5]:
|
||||
pl_str = f"${p['unrealized_pl']:+.2f} ({p['unrealized_plpc']:+.1f}%)"
|
||||
lines.append(f" {p['symbol']:6s} {p['qty']:.0f} @ ${p['avg_entry_price']:.2f}"
|
||||
f" -> ${p['current_price']:.2f} {pl_str}")
|
||||
else:
|
||||
lines.append(" No active positions")
|
||||
|
||||
# Learning status
|
||||
lines.append("-" * 64)
|
||||
lines.append(" Learning Status:")
|
||||
lines.append(f" RL Agent: epsilon={rl_stats['epsilon']:.3f} | "
|
||||
f"loss={rl_stats['avg_loss']:.6f} | "
|
||||
f"{rl_stats['memory_size']:,} experiences")
|
||||
lines.append(f" GA: gen {gen} | best fitness={best_fit:.4f}")
|
||||
|
||||
# Recent trades
|
||||
if self.recent_trades:
|
||||
lines.append("-" * 64)
|
||||
lines.append(" Recent Trades:")
|
||||
for t in self.recent_trades[-5:]:
|
||||
side = t.get('side', '?').upper()
|
||||
symbol = t.get('symbol', '?')
|
||||
pnl = t.get('pnl')
|
||||
if pnl is not None:
|
||||
pnl_str = f" P&L: ${pnl:+.2f}"
|
||||
else:
|
||||
pnl_str = ""
|
||||
price = t.get('entry_price') or t.get('exit_price', 0)
|
||||
lines.append(f" {side:4s} {symbol:6s} "
|
||||
f"{t.get('amount', 0):.1f} @ ${price:.2f}{pnl_str}")
|
||||
|
||||
# Safety
|
||||
if not safety_status['trading_allowed']:
|
||||
lines.append("-" * 64)
|
||||
lines.append(f" !! TRADING HALTED: {safety_status['halt_reason']}")
|
||||
|
||||
# Next events
|
||||
lines.append("-" * 64)
|
||||
now = datetime.utcnow()
|
||||
bt_next = max(0, self.config['backtest']['interval_seconds'] -
|
||||
(now - self.last_backtest).total_seconds())
|
||||
rl_next = max(0, self.config['rl']['train_interval_hours'] * 3600 -
|
||||
(now - self.last_rl_train).total_seconds())
|
||||
ga_next = max(0, self.config['ga']['evolution_interval_hours'] * 3600 -
|
||||
(now - self.last_ga_evolve).total_seconds())
|
||||
|
||||
lines.append(f" Next: backtest {bt_next/60:.0f}m | "
|
||||
f"RL train {rl_next/60:.0f}m | "
|
||||
f"GA evolve {ga_next/3600:.1f}h")
|
||||
lines.append("=" * 64)
|
||||
|
||||
print("\n".join(lines))
|
||||
|
||||
# Update Krystie status file
|
||||
try:
|
||||
markets = {"stocks": stock_status}
|
||||
if self.oanda_broker:
|
||||
markets["forex"] = "OPEN" if self.oanda_broker.is_market_open() else "CLOSED"
|
||||
|
||||
learning_stats = {
|
||||
'rl': rl_stats,
|
||||
'ga': {'generation': gen, 'best_fitness': best_fit},
|
||||
}
|
||||
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_trades = self.store.get_trades(start=today_start, limit=50)
|
||||
|
||||
self.krystie.update_status(
|
||||
portfolio=portfolio,
|
||||
positions=positions,
|
||||
learning_stats=learning_stats,
|
||||
markets=markets,
|
||||
config=self.config['trading'],
|
||||
uptime_seconds=uptime.total_seconds(),
|
||||
today_trades=today_trades,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Krystie status update error: {e}")
|
||||
|
||||
def _load_state(self):
|
||||
"""Load saved state from database"""
|
||||
# Load RL model
|
||||
if self.rl_agent.load(self.store):
|
||||
logger.info("Loaded RL model from checkpoint")
|
||||
|
||||
# Load timing state
|
||||
last_bt = self.store.load_state('last_backtest')
|
||||
if last_bt:
|
||||
self.last_backtest = datetime.fromisoformat(last_bt)
|
||||
|
||||
last_rl = self.store.load_state('last_rl_train')
|
||||
if last_rl:
|
||||
self.last_rl_train = datetime.fromisoformat(last_rl)
|
||||
|
||||
last_ga = self.store.load_state('last_ga_evolve')
|
||||
if last_ga:
|
||||
self.last_ga_evolve = datetime.fromisoformat(last_ga)
|
||||
|
||||
last_report = self.store.load_state('last_daily_report')
|
||||
if last_report:
|
||||
self.last_daily_report = datetime.fromisoformat(last_report)
|
||||
|
||||
logger.info("State loaded from database")
|
||||
|
||||
def _save_state(self):
|
||||
"""Save state to database for recovery"""
|
||||
self.rl_agent.save(self.store)
|
||||
self.store.save_state('last_backtest', self.last_backtest.isoformat())
|
||||
self.store.save_state('last_rl_train', self.last_rl_train.isoformat())
|
||||
self.store.save_state('last_ga_evolve', self.last_ga_evolve.isoformat())
|
||||
self.store.save_state('last_daily_report', self.last_daily_report.isoformat())
|
||||
self.store.save_state('last_shutdown', datetime.utcnow().isoformat())
|
||||
logger.info("State saved to database")
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
"""Handle SIGINT for graceful shutdown"""
|
||||
self._shutdown()
|
||||
|
||||
def _shutdown(self):
|
||||
"""Graceful shutdown"""
|
||||
logger.info("Shutting down BIGGFISH...")
|
||||
self.running = False
|
||||
self.krystie.log_shutdown()
|
||||
self._save_state()
|
||||
self.store.close()
|
||||
logger.info("Shutdown complete. State saved. Resume anytime.")
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="BIGGFISH Autonomous Trader")
|
||||
parser.add_argument('--config', default='config/auto_config.json',
|
||||
help='Path to configuration file')
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = Path(args.config)
|
||||
if not config_path.exists():
|
||||
print(f"Config file not found: {config_path}")
|
||||
print("Copy config/auto_config.json and fill in your Alpaca API keys")
|
||||
sys.exit(1)
|
||||
|
||||
bot = BiggFishAuto(str(config_path))
|
||||
bot.start()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Genetic Algorithm for Strategy Parameter Optimization
|
||||
Evolves a population of StrategyGenome instances to find profitable trading parameters.
|
||||
|
||||
Optimized: uses vectorized backtesting and parallel genome evaluation.
|
||||
"""
|
||||
|
||||
import random
|
||||
import math
|
||||
import json
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# Gene ranges: (min, max, is_int)
|
||||
GENE_RANGES = {
|
||||
'fast_ma_period': (5, 50, True),
|
||||
'slow_ma_period': (20, 200, True),
|
||||
'rsi_period': (7, 28, True),
|
||||
'rsi_overbought': (60, 85, False),
|
||||
'rsi_oversold': (15, 40, False),
|
||||
'bb_period': (10, 30, True),
|
||||
'bb_std': (1.5, 3.0, False),
|
||||
'atr_period': (7, 21, True),
|
||||
'macd_fast': (8, 16, True),
|
||||
'macd_slow': (20, 32, True),
|
||||
'macd_signal': (7, 12, True),
|
||||
'volume_surge_threshold': (1.2, 3.0, False),
|
||||
'stop_loss_atr_mult': (1.0, 2.0, False),
|
||||
'take_profit_atr_mult': (2.5, 5.0, False),
|
||||
'max_position_pct': (0.05, 0.30, False),
|
||||
'min_hold_candles': (1, 24, True),
|
||||
'max_hold_candles': (12, 168, True),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StrategyGenome:
|
||||
"""A genome encoding all tunable strategy parameters"""
|
||||
# Indicator periods
|
||||
fast_ma_period: int = 10
|
||||
slow_ma_period: int = 50
|
||||
rsi_period: int = 14
|
||||
rsi_overbought: float = 70.0
|
||||
rsi_oversold: float = 30.0
|
||||
bb_period: int = 20
|
||||
bb_std: float = 2.0
|
||||
atr_period: int = 14
|
||||
macd_fast: int = 12
|
||||
macd_slow: int = 26
|
||||
macd_signal: int = 9
|
||||
|
||||
# Entry thresholds
|
||||
volume_surge_threshold: float = 1.5
|
||||
|
||||
# Risk management
|
||||
stop_loss_atr_mult: float = 1.5
|
||||
take_profit_atr_mult: float = 3.5
|
||||
max_position_pct: float = 0.20
|
||||
|
||||
# Timing
|
||||
min_hold_candles: int = 2
|
||||
max_hold_candles: int = 48
|
||||
|
||||
# Fitness (set after evaluation)
|
||||
fitness: float = 0.0
|
||||
generation: int = 0
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Dict) -> 'StrategyGenome':
|
||||
valid_fields = {f.name for f in cls.__dataclass_fields__.values()}
|
||||
filtered = {k: v for k, v in d.items() if k in valid_fields}
|
||||
return cls(**filtered)
|
||||
|
||||
@classmethod
|
||||
def random(cls, generation: int = 0) -> 'StrategyGenome':
|
||||
"""Create a random genome within valid parameter ranges"""
|
||||
genes = {}
|
||||
for gene_name, (lo, hi, is_int) in GENE_RANGES.items():
|
||||
if is_int:
|
||||
genes[gene_name] = random.randint(int(lo), int(hi))
|
||||
else:
|
||||
genes[gene_name] = round(random.uniform(lo, hi), 4)
|
||||
|
||||
# Constraint: slow_ma > fast_ma
|
||||
if genes['slow_ma_period'] <= genes['fast_ma_period']:
|
||||
genes['slow_ma_period'] = genes['fast_ma_period'] + random.randint(10, 50)
|
||||
|
||||
# Constraint: macd_slow > macd_fast
|
||||
if genes['macd_slow'] <= genes['macd_fast']:
|
||||
genes['macd_slow'] = genes['macd_fast'] + random.randint(8, 16)
|
||||
|
||||
# Constraint: take_profit must be at least 1.5x stop_loss (enforces min 1.5:1 R/R)
|
||||
if genes['take_profit_atr_mult'] < genes['stop_loss_atr_mult'] * 1.5:
|
||||
genes['take_profit_atr_mult'] = genes['stop_loss_atr_mult'] * random.uniform(1.5, 2.5)
|
||||
|
||||
# Constraint: max_hold > min_hold
|
||||
if genes['max_hold_candles'] <= genes['min_hold_candles']:
|
||||
genes['max_hold_candles'] = genes['min_hold_candles'] + random.randint(10, 50)
|
||||
|
||||
genes['generation'] = generation
|
||||
return cls(**genes)
|
||||
|
||||
|
||||
def _evaluate_genome_worker(genome_dict: Dict, candles_dict: Dict[str, Dict],
|
||||
initial_capital: float, commission_rate: float) -> float:
|
||||
"""
|
||||
Worker function for parallel genome evaluation.
|
||||
Runs in a separate process, so must be a top-level function.
|
||||
Uses vectorized signal generation + fast backtest.
|
||||
"""
|
||||
from strategies.auto_strategy import genome_to_signals
|
||||
from backtest.engine import BacktestEngine
|
||||
import pandas as pd
|
||||
|
||||
genome = StrategyGenome.from_dict(genome_dict)
|
||||
engine = BacktestEngine(initial_capital=initial_capital, commission_rate=commission_rate)
|
||||
|
||||
fitness_scores = []
|
||||
|
||||
for symbol, candle_data in candles_dict.items():
|
||||
df = pd.DataFrame(candle_data)
|
||||
if len(df) < 60:
|
||||
continue
|
||||
|
||||
try:
|
||||
signals = genome_to_signals(genome, df)
|
||||
result = engine.run_fast(signals, df, symbol=symbol)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
m = result.metrics
|
||||
sharpe = max(m.get('sharpe_ratio', 0), 0)
|
||||
max_dd = m.get('max_drawdown', 0)
|
||||
total_trades = m.get('total_trades', 0)
|
||||
|
||||
dd_penalty = max(1 - max_dd / 100, 0)
|
||||
trade_bonus = math.sqrt(max(total_trades, 0))
|
||||
|
||||
if total_trades < 3:
|
||||
trade_bonus *= 0.5
|
||||
|
||||
score = sharpe * dd_penalty * trade_bonus
|
||||
fitness_scores.append(score)
|
||||
|
||||
if not fitness_scores:
|
||||
return 0.0
|
||||
|
||||
return sum(fitness_scores) / len(fitness_scores)
|
||||
|
||||
|
||||
class GeneticEvolver:
|
||||
"""
|
||||
Genetic algorithm for optimizing strategy parameters.
|
||||
Evolves a population of StrategyGenome instances.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict, backtest_engine, store=None):
|
||||
self.population_size = config.get('population_size', 50)
|
||||
self.elite_count = config.get('elite_count', 5)
|
||||
self.mutation_rate = config.get('mutation_rate', 0.15)
|
||||
self.mutation_strength = config.get('mutation_strength', 0.2)
|
||||
self.crossover_rate = config.get('crossover_rate', 0.7)
|
||||
self.tournament_size = config.get('tournament_size', 5)
|
||||
self.parallel_workers = config.get('parallel_workers', 4)
|
||||
|
||||
self.backtest_engine = backtest_engine
|
||||
self.store = store
|
||||
self.population: List[StrategyGenome] = []
|
||||
self.generation = 0
|
||||
self.best_ever: Optional[StrategyGenome] = None
|
||||
|
||||
def initialize_population(self):
|
||||
"""
|
||||
Create initial population.
|
||||
If evolution history exists in DB, load the latest generation.
|
||||
Otherwise, create random genomes.
|
||||
"""
|
||||
if self.store:
|
||||
latest = self.store.get_latest_generation()
|
||||
if latest:
|
||||
self.generation = latest['generation']
|
||||
self.population = [
|
||||
StrategyGenome.from_dict(g) for g in latest['population']
|
||||
]
|
||||
if self.population:
|
||||
self.best_ever = max(self.population, key=lambda g: g.fitness)
|
||||
logger.info(f"Loaded GA population from generation {self.generation} "
|
||||
f"({len(self.population)} genomes)")
|
||||
return
|
||||
|
||||
# Create random population
|
||||
self.population = [
|
||||
StrategyGenome.random(generation=0)
|
||||
for _ in range(self.population_size)
|
||||
]
|
||||
self.generation = 0
|
||||
logger.info(f"Created random population of {self.population_size} genomes")
|
||||
|
||||
def evaluate_fitness(self, genome: StrategyGenome, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame']) -> float:
|
||||
"""
|
||||
Evaluate a genome using vectorized fast backtest.
|
||||
Falls back to callback-based if signals not available.
|
||||
"""
|
||||
from strategies.auto_strategy import genome_to_signals
|
||||
|
||||
fitness_scores = []
|
||||
|
||||
for symbol, df in candles_data.items():
|
||||
if df is None or len(df) < 60:
|
||||
continue
|
||||
|
||||
try:
|
||||
signals = genome_to_signals(genome, df)
|
||||
result = self.backtest_engine.run_fast(signals, df, symbol=symbol)
|
||||
except Exception:
|
||||
# Fallback to callback-based
|
||||
strategy_fn = strategy_fn_factory(genome)
|
||||
result = self.backtest_engine.run(
|
||||
strategy_fn, df, params=genome.to_dict(), symbol=symbol
|
||||
)
|
||||
|
||||
m = result.metrics
|
||||
sharpe = max(m.get('sharpe_ratio', 0), 0)
|
||||
max_dd = m.get('max_drawdown', 0)
|
||||
total_trades = m.get('total_trades', 0)
|
||||
|
||||
dd_penalty = max(1 - max_dd / 100, 0)
|
||||
trade_bonus = math.sqrt(max(total_trades, 0))
|
||||
|
||||
if total_trades < 3:
|
||||
trade_bonus *= 0.5
|
||||
|
||||
score = sharpe * dd_penalty * trade_bonus
|
||||
fitness_scores.append(score)
|
||||
|
||||
if not fitness_scores:
|
||||
return 0.0
|
||||
|
||||
return sum(fitness_scores) / len(fitness_scores)
|
||||
|
||||
def evaluate_population(self, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame']):
|
||||
"""Evaluate all genomes - uses parallel workers if available."""
|
||||
unevaluated = [(i, g) for i, g in enumerate(self.population) if g.fitness == 0.0]
|
||||
|
||||
if not unevaluated:
|
||||
return
|
||||
|
||||
# Prepare serializable candle data for parallel workers
|
||||
candles_dict = {}
|
||||
for symbol, df in candles_data.items():
|
||||
if df is not None and len(df) >= 60:
|
||||
candles_dict[symbol] = {
|
||||
'open': df['open'].values.tolist(),
|
||||
'high': df['high'].values.tolist(),
|
||||
'low': df['low'].values.tolist(),
|
||||
'close': df['close'].values.tolist(),
|
||||
'volume': df['volume'].values.tolist(),
|
||||
}
|
||||
|
||||
if not candles_dict:
|
||||
return
|
||||
|
||||
initial_capital = self.backtest_engine.initial_capital
|
||||
commission_rate = self.backtest_engine.commission_rate
|
||||
|
||||
# Try parallel evaluation
|
||||
if self.parallel_workers > 1 and len(unevaluated) > 4:
|
||||
try:
|
||||
self._evaluate_parallel(
|
||||
unevaluated, candles_dict, initial_capital,
|
||||
commission_rate, strategy_fn_factory, candles_data
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug(f"Parallel evaluation failed, falling back to sequential: {e}")
|
||||
|
||||
# Sequential fallback (still uses vectorized fast path)
|
||||
for idx, (i, genome) in enumerate(unevaluated):
|
||||
genome.fitness = self.evaluate_fitness(
|
||||
genome, strategy_fn_factory, candles_data
|
||||
)
|
||||
if (idx + 1) % 10 == 0:
|
||||
logger.debug(f"Evaluated {idx+1}/{len(unevaluated)} genomes")
|
||||
|
||||
def _evaluate_parallel(self, unevaluated, candles_dict, initial_capital,
|
||||
commission_rate, strategy_fn_factory, candles_data):
|
||||
"""Evaluate genomes in parallel using ProcessPoolExecutor."""
|
||||
genome_dicts = [(i, g.to_dict()) for i, g in unevaluated]
|
||||
|
||||
with ProcessPoolExecutor(max_workers=self.parallel_workers) as executor:
|
||||
futures = {}
|
||||
for i, gd in genome_dicts:
|
||||
fut = executor.submit(
|
||||
_evaluate_genome_worker, gd, candles_dict,
|
||||
initial_capital, commission_rate
|
||||
)
|
||||
futures[fut] = i
|
||||
|
||||
done_count = 0
|
||||
for future in as_completed(futures):
|
||||
pop_idx = futures[future]
|
||||
try:
|
||||
fitness = future.result(timeout=30)
|
||||
self.population[pop_idx].fitness = fitness
|
||||
except Exception:
|
||||
# Fallback for this genome
|
||||
self.population[pop_idx].fitness = self.evaluate_fitness(
|
||||
self.population[pop_idx], strategy_fn_factory, candles_data
|
||||
)
|
||||
done_count += 1
|
||||
if done_count % 10 == 0:
|
||||
logger.debug(f"Evaluated {done_count}/{len(unevaluated)} genomes (parallel)")
|
||||
|
||||
def select_parent(self) -> StrategyGenome:
|
||||
"""Tournament selection"""
|
||||
tournament = random.sample(
|
||||
self.population,
|
||||
min(self.tournament_size, len(self.population))
|
||||
)
|
||||
return max(tournament, key=lambda g: g.fitness)
|
||||
|
||||
def crossover(self, parent1: StrategyGenome,
|
||||
parent2: StrategyGenome) -> StrategyGenome:
|
||||
"""Uniform crossover: for each gene, randomly pick from parent1 or parent2"""
|
||||
child_genes = {}
|
||||
p1 = parent1.to_dict()
|
||||
p2 = parent2.to_dict()
|
||||
|
||||
for gene_name in GENE_RANGES:
|
||||
child_genes[gene_name] = p1[gene_name] if random.random() < 0.5 else p2[gene_name]
|
||||
|
||||
# Repair constraints
|
||||
if child_genes['slow_ma_period'] <= child_genes['fast_ma_period']:
|
||||
child_genes['slow_ma_period'] = child_genes['fast_ma_period'] + 10
|
||||
|
||||
if child_genes['macd_slow'] <= child_genes['macd_fast']:
|
||||
child_genes['macd_slow'] = child_genes['macd_fast'] + 8
|
||||
|
||||
if child_genes['take_profit_atr_mult'] <= child_genes['stop_loss_atr_mult']:
|
||||
child_genes['take_profit_atr_mult'] = child_genes['stop_loss_atr_mult'] + 0.5
|
||||
|
||||
if child_genes['max_hold_candles'] <= child_genes['min_hold_candles']:
|
||||
child_genes['max_hold_candles'] = child_genes['min_hold_candles'] + 10
|
||||
|
||||
child_genes['fitness'] = 0.0
|
||||
child_genes['generation'] = self.generation + 1
|
||||
return StrategyGenome(**child_genes)
|
||||
|
||||
def mutate(self, genome: StrategyGenome) -> StrategyGenome:
|
||||
"""Gaussian mutation on each gene with probability mutation_rate"""
|
||||
genes = genome.to_dict()
|
||||
|
||||
for gene_name, (lo, hi, is_int) in GENE_RANGES.items():
|
||||
if random.random() < self.mutation_rate:
|
||||
gene_range = hi - lo
|
||||
delta = random.gauss(0, gene_range * self.mutation_strength)
|
||||
|
||||
new_val = genes[gene_name] + delta
|
||||
new_val = max(lo, min(hi, new_val))
|
||||
|
||||
if is_int:
|
||||
new_val = round(new_val)
|
||||
else:
|
||||
new_val = round(new_val, 4)
|
||||
|
||||
genes[gene_name] = new_val
|
||||
|
||||
# Repair constraints after mutation
|
||||
if genes['slow_ma_period'] <= genes['fast_ma_period']:
|
||||
genes['slow_ma_period'] = genes['fast_ma_period'] + 10
|
||||
if genes['macd_slow'] <= genes['macd_fast']:
|
||||
genes['macd_slow'] = genes['macd_fast'] + 8
|
||||
if genes['take_profit_atr_mult'] <= genes['stop_loss_atr_mult']:
|
||||
genes['take_profit_atr_mult'] = genes['stop_loss_atr_mult'] + 0.5
|
||||
if genes['max_hold_candles'] <= genes['min_hold_candles']:
|
||||
genes['max_hold_candles'] = genes['min_hold_candles'] + 10
|
||||
|
||||
genes['fitness'] = 0.0
|
||||
genes['generation'] = self.generation + 1
|
||||
return StrategyGenome.from_dict(genes)
|
||||
|
||||
def evolve_generation(self, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame']) -> Dict:
|
||||
"""
|
||||
Run one generation of evolution:
|
||||
1. Evaluate fitness of all genomes
|
||||
2. Sort by fitness
|
||||
3. Keep elite_count best unchanged
|
||||
4. Fill remaining via tournament selection + crossover + mutation
|
||||
5. Save to database
|
||||
"""
|
||||
# Evaluate
|
||||
self.evaluate_population(strategy_fn_factory, candles_data)
|
||||
|
||||
# Sort by fitness
|
||||
self.population.sort(key=lambda g: g.fitness, reverse=True)
|
||||
|
||||
best = self.population[0]
|
||||
avg_fitness = sum(g.fitness for g in self.population) / len(self.population)
|
||||
|
||||
if self.best_ever is None or best.fitness > self.best_ever.fitness:
|
||||
self.best_ever = StrategyGenome.from_dict(best.to_dict())
|
||||
self.best_ever.fitness = best.fitness
|
||||
|
||||
# Elitism: keep top N unchanged
|
||||
new_population = [
|
||||
StrategyGenome.from_dict(g.to_dict())
|
||||
for g in self.population[:self.elite_count]
|
||||
]
|
||||
# Preserve their fitness
|
||||
for i in range(min(self.elite_count, len(self.population))):
|
||||
new_population[i].fitness = self.population[i].fitness
|
||||
|
||||
# Fill the rest
|
||||
while len(new_population) < self.population_size:
|
||||
parent1 = self.select_parent()
|
||||
parent2 = self.select_parent()
|
||||
|
||||
if random.random() < self.crossover_rate:
|
||||
child = self.crossover(parent1, parent2)
|
||||
else:
|
||||
child = StrategyGenome.from_dict(parent1.to_dict())
|
||||
child.fitness = 0.0
|
||||
|
||||
child = self.mutate(child)
|
||||
new_population.append(child)
|
||||
|
||||
self.population = new_population
|
||||
self.generation += 1
|
||||
|
||||
# Save to database
|
||||
if self.store:
|
||||
self.store.record_generation(
|
||||
self.generation,
|
||||
[g.to_dict() for g in self.population],
|
||||
best.fitness,
|
||||
avg_fitness
|
||||
)
|
||||
|
||||
stats = {
|
||||
'generation': self.generation,
|
||||
'best_fitness': round(best.fitness, 4),
|
||||
'avg_fitness': round(avg_fitness, 4),
|
||||
'best_genome': best.to_dict(),
|
||||
}
|
||||
|
||||
logger.info(f"GA Gen {self.generation}: best={best.fitness:.4f} avg={avg_fitness:.4f}")
|
||||
return stats
|
||||
|
||||
def run_evolution_cycle(self, strategy_fn_factory,
|
||||
candles_data: Dict[str, 'pd.DataFrame'],
|
||||
num_generations: int = 10) -> Optional[StrategyGenome]:
|
||||
"""
|
||||
Run multiple generations.
|
||||
Returns the best genome found.
|
||||
"""
|
||||
for _ in range(num_generations):
|
||||
self.evolve_generation(strategy_fn_factory, candles_data)
|
||||
|
||||
return self.get_best_genome()
|
||||
|
||||
def get_best_genome(self) -> Optional[StrategyGenome]:
|
||||
"""Return the highest-fitness genome"""
|
||||
if self.best_ever:
|
||||
return self.best_ever
|
||||
if self.population:
|
||||
return max(self.population, key=lambda g: g.fitness)
|
||||
return None
|
||||
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
OANDA broker interface for forex paper trading.
|
||||
Uses OANDA v20 REST API - no extra dependency, just requests.
|
||||
"""
|
||||
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class OandaBroker:
|
||||
"""OANDA practice (paper) trading broker interface"""
|
||||
|
||||
PRACTICE_URL = "https://api-fxpractice.oanda.com"
|
||||
LIVE_URL = "https://api-fxtrade.oanda.com"
|
||||
|
||||
# Forex pairs trade 24/5 (Sun 5PM ET to Fri 5PM ET)
|
||||
# Map our timeframes to OANDA granularity
|
||||
TF_MAP = {
|
||||
'1m': 'M1', '5m': 'M5', '15m': 'M15',
|
||||
'1h': 'H1', '4h': 'H4', '1d': 'D',
|
||||
}
|
||||
|
||||
def __init__(self, config: Dict):
|
||||
self.config = config
|
||||
self.api_token = config['api_token']
|
||||
self.account_id = config['account_id']
|
||||
self.base_url = self.PRACTICE_URL if config.get('practice', True) else self.LIVE_URL
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'Authorization': f'Bearer {self.api_token}',
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
|
||||
# Verify connection
|
||||
try:
|
||||
acct = self._get(f'/v3/accounts/{self.account_id}/summary')
|
||||
balance = acct['account']['balance']
|
||||
currency = acct['account']['currency']
|
||||
logger.info(f"OANDA connected (PRACTICE) - Balance: {currency} {balance}")
|
||||
except Exception as e:
|
||||
logger.error(f"OANDA connection failed: {e}")
|
||||
raise
|
||||
|
||||
def _get(self, path: str, params: Dict = None) -> Dict:
|
||||
resp = self.session.get(f'{self.base_url}{path}', params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def _post(self, path: str, data: Dict) -> Dict:
|
||||
resp = self.session.post(f'{self.base_url}{path}', json=data, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def _put(self, path: str, data: Dict) -> Dict:
|
||||
resp = self.session.put(f'{self.base_url}{path}', json=data, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# --- Account / Portfolio ---
|
||||
|
||||
def get_account(self) -> Dict:
|
||||
data = self._get(f'/v3/accounts/{self.account_id}/summary')
|
||||
return data['account']
|
||||
|
||||
def get_portfolio(self) -> Dict:
|
||||
acct = self.get_account()
|
||||
nav = float(acct['NAV'])
|
||||
balance = float(acct['balance'])
|
||||
unrealized_pl = float(acct['unrealizedPL'])
|
||||
pl = float(acct['pl'])
|
||||
return {
|
||||
'equity': nav,
|
||||
'cash': balance,
|
||||
'buying_power': float(acct.get('marginAvailable', balance)),
|
||||
'portfolio_value': nav,
|
||||
'long_market_value': nav - balance,
|
||||
'day_pnl': unrealized_pl,
|
||||
'day_pnl_pct': (unrealized_pl / balance * 100) if balance > 0 else 0,
|
||||
}
|
||||
|
||||
def get_positions(self) -> List[Dict]:
|
||||
data = self._get(f'/v3/accounts/{self.account_id}/openPositions')
|
||||
positions = []
|
||||
for p in data.get('positions', []):
|
||||
# OANDA separates long/short
|
||||
long_units = int(p['long']['units']) if p['long']['units'] != '0' else 0
|
||||
short_units = abs(int(p['short']['units'])) if p['short']['units'] != '0' else 0
|
||||
|
||||
if long_units > 0:
|
||||
unrealized = float(p['long']['unrealizedPL'])
|
||||
avg_price = float(p['long']['averagePrice'])
|
||||
# Estimate current price from avg + pnl
|
||||
current_price = avg_price + (unrealized / long_units) if long_units else avg_price
|
||||
positions.append({
|
||||
'symbol': p['instrument'],
|
||||
'qty': long_units,
|
||||
'market_value': long_units * current_price,
|
||||
'cost_basis': long_units * avg_price,
|
||||
'unrealized_pl': unrealized,
|
||||
'unrealized_plpc': (unrealized / (long_units * avg_price) * 100)
|
||||
if avg_price > 0 else 0,
|
||||
'current_price': current_price,
|
||||
'avg_entry_price': avg_price,
|
||||
})
|
||||
if short_units > 0:
|
||||
unrealized = float(p['short']['unrealizedPL'])
|
||||
avg_price = float(p['short']['averagePrice'])
|
||||
current_price = avg_price - (unrealized / short_units) if short_units else avg_price
|
||||
positions.append({
|
||||
'symbol': p['instrument'],
|
||||
'qty': -short_units,
|
||||
'market_value': short_units * current_price,
|
||||
'cost_basis': short_units * avg_price,
|
||||
'unrealized_pl': unrealized,
|
||||
'unrealized_plpc': (unrealized / (short_units * avg_price) * 100)
|
||||
if avg_price > 0 else 0,
|
||||
'current_price': current_price,
|
||||
'avg_entry_price': avg_price,
|
||||
})
|
||||
return positions
|
||||
|
||||
def is_market_open(self) -> bool:
|
||||
"""Forex is open 24/5 - closed Saturday and most of Sunday"""
|
||||
now = datetime.utcnow()
|
||||
# Closed: Friday 22:00 UTC to Sunday 22:00 UTC (roughly)
|
||||
if now.weekday() == 5: # Saturday
|
||||
return False
|
||||
if now.weekday() == 6 and now.hour < 22: # Sunday before 22:00
|
||||
return False
|
||||
if now.weekday() == 4 and now.hour >= 22: # Friday after 22:00
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_market_hours(self) -> Dict:
|
||||
return {
|
||||
'is_open': self.is_market_open(),
|
||||
'next_open': None,
|
||||
'next_close': None,
|
||||
}
|
||||
|
||||
# --- Orders ---
|
||||
|
||||
def place_market_order(self, symbol: str, qty, side: str) -> Dict:
|
||||
"""Place a market order. qty is in units (not lots)."""
|
||||
units = int(qty) if side == 'buy' else -int(qty)
|
||||
data = {
|
||||
'order': {
|
||||
'type': 'MARKET',
|
||||
'instrument': symbol,
|
||||
'units': str(units),
|
||||
'timeInForce': 'FOK',
|
||||
}
|
||||
}
|
||||
try:
|
||||
result = self._post(f'/v3/accounts/{self.account_id}/orders', data)
|
||||
fill = result.get('orderFillTransaction', {})
|
||||
order_id = fill.get('id', result.get('orderCreateTransaction', {}).get('id', ''))
|
||||
logger.info(f"OANDA {side.upper()} {abs(units)} {symbol}")
|
||||
return {
|
||||
'id': order_id,
|
||||
'symbol': symbol,
|
||||
'qty': abs(units),
|
||||
'side': side,
|
||||
'type': 'market',
|
||||
'status': 'filled' if fill else 'pending',
|
||||
'fill_price': float(fill.get('price', 0)) if fill else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"OANDA order error: {e}")
|
||||
raise
|
||||
|
||||
def place_limit_order(self, symbol: str, qty, side: str, limit_price: float) -> Dict:
|
||||
units = int(qty) if side == 'buy' else -int(qty)
|
||||
data = {
|
||||
'order': {
|
||||
'type': 'LIMIT',
|
||||
'instrument': symbol,
|
||||
'units': str(units),
|
||||
'price': f'{limit_price:.5f}',
|
||||
'timeInForce': 'GTC',
|
||||
}
|
||||
}
|
||||
result = self._post(f'/v3/accounts/{self.account_id}/orders', data)
|
||||
order = result.get('orderCreateTransaction', {})
|
||||
return {
|
||||
'id': order.get('id', ''),
|
||||
'symbol': symbol,
|
||||
'qty': abs(units),
|
||||
'side': side,
|
||||
'type': 'limit',
|
||||
'limit_price': limit_price,
|
||||
'status': 'pending',
|
||||
}
|
||||
|
||||
def cancel_order(self, order_id: str) -> bool:
|
||||
try:
|
||||
self._put(f'/v3/accounts/{self.account_id}/orders/{order_id}/cancel', {})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_orders(self, status: str = "open") -> List[Dict]:
|
||||
state = 'PENDING' if status == 'open' else 'ALL'
|
||||
data = self._get(f'/v3/accounts/{self.account_id}/orders', {'state': state})
|
||||
return [
|
||||
{
|
||||
'id': o['id'],
|
||||
'symbol': o.get('instrument', ''),
|
||||
'qty': abs(int(o.get('units', 0))),
|
||||
'side': 'buy' if int(o.get('units', 0)) > 0 else 'sell',
|
||||
'type': o.get('type', '').lower(),
|
||||
'status': o.get('state', '').lower(),
|
||||
'created_at': o.get('createTime', ''),
|
||||
}
|
||||
for o in data.get('orders', [])
|
||||
]
|
||||
|
||||
# --- Market Data ---
|
||||
|
||||
def get_bars(self, symbol: str, timeframe: str = "1d", limit: int = 100) -> List[Dict]:
|
||||
gran = self.TF_MAP.get(timeframe, 'H1')
|
||||
params = {'granularity': gran, 'count': min(limit, 5000)}
|
||||
try:
|
||||
data = self._get(f'/v3/instruments/{symbol}/candles', params)
|
||||
return self._parse_candles(data.get('candles', []))
|
||||
except Exception as e:
|
||||
logger.error(f"OANDA bars error for {symbol}: {e}")
|
||||
return []
|
||||
|
||||
def fetch_bars_range(self, symbol: str, timeframe: str = "1h",
|
||||
start=None, end=None) -> List[Dict]:
|
||||
gran = self.TF_MAP.get(timeframe, 'H1')
|
||||
params = {'granularity': gran, 'price': 'M'}
|
||||
|
||||
if start:
|
||||
if isinstance(start, datetime):
|
||||
params['from'] = start.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
else:
|
||||
params['from'] = str(start)
|
||||
if end:
|
||||
if isinstance(end, datetime):
|
||||
params['to'] = end.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
else:
|
||||
params['to'] = str(end)
|
||||
|
||||
if 'from' not in params:
|
||||
params['count'] = 500
|
||||
|
||||
try:
|
||||
data = self._get(f'/v3/instruments/{symbol}/candles', params)
|
||||
return self._parse_candles(data.get('candles', []))
|
||||
except Exception as e:
|
||||
logger.error(f"OANDA bars range error for {symbol}: {e}")
|
||||
return []
|
||||
|
||||
def get_latest_price(self, symbol: str) -> Optional[float]:
|
||||
try:
|
||||
data = self._get(f'/v3/instruments/{symbol}/candles',
|
||||
{'granularity': 'M1', 'count': 1, 'price': 'M'})
|
||||
candles = data.get('candles', [])
|
||||
if candles:
|
||||
return float(candles[-1]['mid']['c'])
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"OANDA price error for {symbol}: {e}")
|
||||
return None
|
||||
|
||||
def close_position(self, symbol: str) -> bool:
|
||||
"""Close entire position for an instrument"""
|
||||
try:
|
||||
# Close long
|
||||
try:
|
||||
self._put(f'/v3/accounts/{self.account_id}/positions/{symbol}/close',
|
||||
{'longUnits': 'ALL'})
|
||||
except Exception:
|
||||
pass
|
||||
# Close short
|
||||
try:
|
||||
self._put(f'/v3/accounts/{self.account_id}/positions/{symbol}/close',
|
||||
{'shortUnits': 'ALL'})
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"Closed OANDA position: {symbol}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing OANDA position {symbol}: {e}")
|
||||
return False
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def _parse_candles(self, candles: List[Dict]) -> List[Dict]:
|
||||
"""Convert OANDA candles to our standard format"""
|
||||
result = []
|
||||
for c in candles:
|
||||
if not c.get('complete', True) and len(candles) > 1:
|
||||
continue # Skip incomplete candles unless it's the only one
|
||||
mid = c.get('mid', {})
|
||||
ts = c.get('time', '')
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||
ts_ms = int(dt.timestamp() * 1000)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
result.append({
|
||||
'timestamp': ts_ms,
|
||||
'open': float(mid.get('o', 0)),
|
||||
'high': float(mid.get('h', 0)),
|
||||
'low': float(mid.get('l', 0)),
|
||||
'close': float(mid.get('c', 0)),
|
||||
'volume': int(c.get('volume', 0)),
|
||||
})
|
||||
return result
|
||||
|
||||
def get_tradeable_instruments(self) -> List[str]:
|
||||
"""Get list of available forex instruments"""
|
||||
data = self._get(f'/v3/accounts/{self.account_id}/instruments')
|
||||
return [i['name'] for i in data.get('instruments', [])
|
||||
if i.get('type') == 'CURRENCY']
|
||||
Reference in New Issue
Block a user