mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-19 04:58:07 +00:00
Add Docker setup with AI ensemble integration and execution loop
Dockerize the order executor with python:3.11-slim, add docker-compose with config volume mount for hot-reload of system.yaml settings. Integrate AI ensemble validation into order execution pipeline and add configurable interval loop (default 60s) to replace container restart cycling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
426f5e7d49
commit
ef950f25dd
@@ -0,0 +1,11 @@
|
|||||||
|
.git/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
logs/
|
||||||
|
models/
|
||||||
|
.claude/
|
||||||
|
venv/
|
||||||
|
data/
|
||||||
|
fx-quant/
|
||||||
|
*.pdf
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# LightGBM needs libgomp
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libgomp1 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.docker.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.docker.txt
|
||||||
|
|
||||||
|
COPY config/system.yaml config/system.yaml
|
||||||
|
COPY src/ src/
|
||||||
|
COPY sql/ sql/
|
||||||
|
|
||||||
|
RUN mkdir -p logs models
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
CMD ["python", "src/order_executor.py"]
|
||||||
+8
-1
@@ -26,10 +26,16 @@ features:
|
|||||||
volatility_window: 20
|
volatility_window: 20
|
||||||
|
|
||||||
ai:
|
ai:
|
||||||
model: "local-ensemble" # example: 'openai', 'local-llm', 'ensemble'
|
model: "local-ensemble"
|
||||||
confidence_threshold: 0.85
|
confidence_threshold: 0.85
|
||||||
retriever_enabled: true
|
retriever_enabled: true
|
||||||
retriever_source: "supabase"
|
retriever_source: "supabase"
|
||||||
|
ensemble_models: ["logistic_regression", "random_forest", "gradient_boosting"]
|
||||||
|
backtest_validation_window: 50
|
||||||
|
sanity_checks:
|
||||||
|
rsi_overbought: 80
|
||||||
|
rsi_oversold: 20
|
||||||
|
volatility_multiplier: 3.0
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
rule: "sma_cross" # human-readable name of the rule
|
rule: "sma_cross" # human-readable name of the rule
|
||||||
@@ -43,6 +49,7 @@ execution:
|
|||||||
paper_mode: true
|
paper_mode: true
|
||||||
canary_size_pct: 0.01
|
canary_size_pct: 0.01
|
||||||
max_positions: 3
|
max_positions: 3
|
||||||
|
interval_seconds: 60
|
||||||
|
|
||||||
supabase:
|
supabase:
|
||||||
url: "https://<your>.supabase.co"
|
url: "https://<your>.supabase.co"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
fx-quant:
|
||||||
|
build: .
|
||||||
|
container_name: fx-quant
|
||||||
|
env_file:
|
||||||
|
- config/.env
|
||||||
|
volumes:
|
||||||
|
- ./config/system.yaml:/app/config/system.yaml:ro
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./models:/app/models
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
pandas>=2.0
|
||||||
|
numpy>=1.24
|
||||||
|
scipy>=1.10
|
||||||
|
scikit-learn==1.8.0
|
||||||
|
lightgbm>=4.0
|
||||||
|
matplotlib>=3.7
|
||||||
|
plotly>=5.0
|
||||||
|
pyarrow>=14.0
|
||||||
|
oandapyV20==0.7.2
|
||||||
|
requests>=2.28
|
||||||
|
supabase>=2.0
|
||||||
|
python-dotenv>=1.0
|
||||||
|
PyYAML>=6.0
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
# src/ai_wrapper.py
|
||||||
|
"""
|
||||||
|
AI Decision Wrapper for fx-quant.
|
||||||
|
Validates trading signals using a local ML ensemble (scikit-learn + LightGBM)
|
||||||
|
before allowing order execution. Low-confidence or sanity-failed signals
|
||||||
|
are rejected and logged for human review.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import warnings
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.linear_model import LogisticRegression
|
||||||
|
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
|
||||||
|
|
||||||
|
from config_loader import load_config, get_project_root
|
||||||
|
from backtester import run_backtest, generate_signals
|
||||||
|
|
||||||
|
# Try to use LightGBM if available, otherwise fall back to sklearn GBM
|
||||||
|
try:
|
||||||
|
from lightgbm import LGBMClassifier
|
||||||
|
HAS_LGBM = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_LGBM = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature engineering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FEATURE_COLS = ["sma_3", "sma_20", "rsi_14", "atr_14", "vol_20", "ret", "close"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_feature_vector(df, idx=-1):
|
||||||
|
"""
|
||||||
|
Extract model input features from the signal DataFrame at a given bar index.
|
||||||
|
|
||||||
|
Features are normalized by dividing price-scale columns by close price.
|
||||||
|
Returns a 1D numpy array ready for model prediction.
|
||||||
|
"""
|
||||||
|
row = df.iloc[idx]
|
||||||
|
close = row["close"]
|
||||||
|
if close == 0:
|
||||||
|
close = 1e-10 # avoid division by zero
|
||||||
|
|
||||||
|
features = []
|
||||||
|
for col in FEATURE_COLS:
|
||||||
|
val = row.get(col, 0.0)
|
||||||
|
if pd.isna(val):
|
||||||
|
val = 0.0
|
||||||
|
# Normalize price-scale features relative to close
|
||||||
|
if col in ("sma_3", "sma_20", "atr_14"):
|
||||||
|
val = val / close
|
||||||
|
features.append(float(val))
|
||||||
|
|
||||||
|
return pd.DataFrame([features], columns=FEATURE_COLS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Context builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_context(df, instrument, supabase_client=None, cfg=None):
|
||||||
|
"""
|
||||||
|
Assemble retrieval context: last 50 bars of features, recent backtest trades.
|
||||||
|
Used for rationale generation in decision logging.
|
||||||
|
|
||||||
|
Returns dict with context summary.
|
||||||
|
"""
|
||||||
|
cfg = cfg or {}
|
||||||
|
ai_cfg = cfg.get("ai", {})
|
||||||
|
context = {
|
||||||
|
"instrument": instrument,
|
||||||
|
"bars_available": len(df),
|
||||||
|
"last_close": float(df["close"].iloc[-1]) if len(df) > 0 else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Recent feature summary (last 50 bars)
|
||||||
|
tail = df.tail(50)
|
||||||
|
for col in FEATURE_COLS:
|
||||||
|
if col in tail.columns:
|
||||||
|
context[f"{col}_mean"] = float(tail[col].mean()) if not tail[col].isna().all() else None
|
||||||
|
|
||||||
|
# Optional: pull recent rows from Supabase for retrieval context
|
||||||
|
if ai_cfg.get("retriever_enabled") and supabase_client is not None:
|
||||||
|
try:
|
||||||
|
table = cfg.get("supabase", {}).get("table", "fx_candles")
|
||||||
|
resp = (
|
||||||
|
supabase_client.table(table)
|
||||||
|
.select("time,close,ret")
|
||||||
|
.eq("instrument", instrument)
|
||||||
|
.order("time", desc=True)
|
||||||
|
.limit(10)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
recent = resp.data or []
|
||||||
|
context["recent_supabase_rows"] = len(recent)
|
||||||
|
except Exception as e:
|
||||||
|
context["recent_supabase_rows"] = 0
|
||||||
|
context["retriever_error"] = str(e)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ensemble training
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_feature_matrix(df):
|
||||||
|
"""Build feature matrix from DataFrame. Returns (X, feature_names)."""
|
||||||
|
close = df["close"].replace(0, 1e-10)
|
||||||
|
X = pd.DataFrame(index=df.index)
|
||||||
|
for col in FEATURE_COLS:
|
||||||
|
if col not in df.columns:
|
||||||
|
X[col] = 0.0
|
||||||
|
elif col in ("sma_3", "sma_20", "atr_14"):
|
||||||
|
X[col] = df[col] / close
|
||||||
|
else:
|
||||||
|
X[col] = df[col]
|
||||||
|
X = X.fillna(0.0)
|
||||||
|
return X, list(X.columns)
|
||||||
|
|
||||||
|
|
||||||
|
def train_ensemble(df, strategy_cfg):
|
||||||
|
"""
|
||||||
|
Train 3 models on historical data:
|
||||||
|
- LogisticRegression
|
||||||
|
- RandomForestClassifier
|
||||||
|
- GradientBoostingClassifier (or LGBMClassifier if available)
|
||||||
|
|
||||||
|
Target: whether the signal led to a profitable next bar
|
||||||
|
(next-bar return > 0 when signal == 1).
|
||||||
|
|
||||||
|
Uses walk-forward split: train on first 70%, validate on last 30%.
|
||||||
|
Returns (list of fitted model objects, validation metrics dict).
|
||||||
|
"""
|
||||||
|
# Need signal column
|
||||||
|
if "signal" not in df.columns:
|
||||||
|
df = generate_signals(df, strategy_cfg)
|
||||||
|
|
||||||
|
# Build target: next-bar return is positive AND we have a long signal
|
||||||
|
next_ret = df["ret"].shift(-1)
|
||||||
|
target = ((next_ret > 0) & (df["signal"] == 1)).astype(int)
|
||||||
|
|
||||||
|
# Drop last row (no next-bar return) and any NaN rows
|
||||||
|
valid_mask = next_ret.notna()
|
||||||
|
df_valid = df[valid_mask]
|
||||||
|
target = target[valid_mask]
|
||||||
|
|
||||||
|
X, feature_names = _build_feature_matrix(df_valid)
|
||||||
|
y = target.values
|
||||||
|
|
||||||
|
if len(X) < 50:
|
||||||
|
print(f" AI: Not enough data to train ensemble ({len(X)} rows). Skipping.")
|
||||||
|
return [], {"error": "insufficient_data", "rows": len(X)}
|
||||||
|
|
||||||
|
# Walk-forward split: 70/30
|
||||||
|
split_idx = int(len(X) * 0.7)
|
||||||
|
X_train, X_val = X[:split_idx], X[split_idx:]
|
||||||
|
y_train, y_val = y[:split_idx], y[split_idx:]
|
||||||
|
|
||||||
|
# Train models
|
||||||
|
models = []
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
|
||||||
|
lr = LogisticRegression(max_iter=500, random_state=42)
|
||||||
|
lr.fit(X_train, y_train)
|
||||||
|
models.append(("logistic_regression", lr))
|
||||||
|
|
||||||
|
rf = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
|
||||||
|
rf.fit(X_train, y_train)
|
||||||
|
models.append(("random_forest", rf))
|
||||||
|
|
||||||
|
if HAS_LGBM:
|
||||||
|
gbm = LGBMClassifier(n_estimators=100, max_depth=5, random_state=42, verbose=-1)
|
||||||
|
else:
|
||||||
|
gbm = GradientBoostingClassifier(n_estimators=100, max_depth=5, random_state=42)
|
||||||
|
gbm.fit(X_train, y_train)
|
||||||
|
models.append(("gradient_boosting", gbm))
|
||||||
|
|
||||||
|
# Validation metrics
|
||||||
|
val_scores = {}
|
||||||
|
for name, model in models:
|
||||||
|
score = model.score(X_val, y_val)
|
||||||
|
val_scores[name] = round(score, 4)
|
||||||
|
|
||||||
|
print(f" AI: Ensemble trained — validation scores: {val_scores}")
|
||||||
|
return models, val_scores
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ensemble prediction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def ensemble_predict(models, feature_vector):
|
||||||
|
"""
|
||||||
|
Each model predicts probability of profitable outcome.
|
||||||
|
|
||||||
|
Returns (confidence, predictions, agreement):
|
||||||
|
- confidence: mean probability across models
|
||||||
|
- predictions: list of per-model probabilities
|
||||||
|
- agreement: all models agree on direction (all > 0.5 or all < 0.5)
|
||||||
|
"""
|
||||||
|
if not models:
|
||||||
|
return 0.0, [], False
|
||||||
|
|
||||||
|
predictions = []
|
||||||
|
for name, model in models:
|
||||||
|
proba = model.predict_proba(feature_vector)
|
||||||
|
# probability of class 1 (profitable)
|
||||||
|
p = float(proba[0][1]) if proba.shape[1] > 1 else float(proba[0][0])
|
||||||
|
predictions.append(p)
|
||||||
|
|
||||||
|
confidence = float(np.mean(predictions))
|
||||||
|
# Agreement: all above 0.5 or all below 0.5
|
||||||
|
all_bullish = all(p > 0.5 for p in predictions)
|
||||||
|
all_bearish = all(p <= 0.5 for p in predictions)
|
||||||
|
agreement = all_bullish or all_bearish
|
||||||
|
|
||||||
|
return confidence, predictions, agreement
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sanity checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def sanity_checks(df, instrument, cfg):
|
||||||
|
"""
|
||||||
|
Run deterministic sanity checks on the latest bar:
|
||||||
|
- RSI not in extreme territory against the signal
|
||||||
|
- Volatility not excessively high (ATR > 3x rolling mean)
|
||||||
|
- Price not gapping (|ret| > 5x vol)
|
||||||
|
|
||||||
|
Returns (passed, reasons) where reasons lists any failed checks.
|
||||||
|
"""
|
||||||
|
ai_cfg = cfg.get("ai", {})
|
||||||
|
sc_cfg = ai_cfg.get("sanity_checks", {})
|
||||||
|
rsi_ob = sc_cfg.get("rsi_overbought", 80)
|
||||||
|
rsi_os = sc_cfg.get("rsi_oversold", 20)
|
||||||
|
vol_mult = sc_cfg.get("volatility_multiplier", 3.0)
|
||||||
|
|
||||||
|
reasons = []
|
||||||
|
latest = df.iloc[-1]
|
||||||
|
signal = int(latest.get("signal", 0))
|
||||||
|
|
||||||
|
# RSI check
|
||||||
|
rsi = latest.get("rsi_14", 50.0)
|
||||||
|
if pd.notna(rsi):
|
||||||
|
if signal == 1 and rsi > rsi_ob:
|
||||||
|
reasons.append(f"RSI={rsi:.1f} > {rsi_ob} (overbought) conflicts with BUY signal")
|
||||||
|
elif signal == 0 and rsi < rsi_os:
|
||||||
|
reasons.append(f"RSI={rsi:.1f} < {rsi_os} (oversold) conflicts with FLAT signal")
|
||||||
|
|
||||||
|
# ATR volatility check
|
||||||
|
atr = latest.get("atr_14", None)
|
||||||
|
if atr is not None and pd.notna(atr) and "atr_14" in df.columns:
|
||||||
|
atr_mean = df["atr_14"].rolling(50).mean().iloc[-1]
|
||||||
|
if pd.notna(atr_mean) and atr_mean > 0 and atr > vol_mult * atr_mean:
|
||||||
|
reasons.append(f"ATR={atr:.6f} > {vol_mult}x rolling mean ({atr_mean:.6f})")
|
||||||
|
|
||||||
|
# Gap check: |ret| > 5x vol_20
|
||||||
|
ret = latest.get("ret", 0.0)
|
||||||
|
vol = latest.get("vol_20", None)
|
||||||
|
if vol is not None and pd.notna(vol) and vol > 0 and pd.notna(ret):
|
||||||
|
if abs(ret) > 5.0 * vol:
|
||||||
|
reasons.append(f"|ret|={abs(ret):.6f} > 5x vol_20 ({vol:.6f}), possible gap")
|
||||||
|
|
||||||
|
passed = len(reasons) == 0
|
||||||
|
return passed, reasons
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backtest recent window
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def backtest_recent_window(df, strategy_cfg, window=50):
|
||||||
|
"""
|
||||||
|
Run backtester on the last N bars to check if the strategy
|
||||||
|
is profitable in the recent window.
|
||||||
|
|
||||||
|
Returns (profitable, metrics).
|
||||||
|
"""
|
||||||
|
if len(df) < window:
|
||||||
|
window = len(df)
|
||||||
|
|
||||||
|
if window < 10:
|
||||||
|
return False, {"error": "insufficient_data", "bars": window}
|
||||||
|
|
||||||
|
recent_df = df.tail(window).copy()
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = run_backtest(recent_df, strategy_cfg)
|
||||||
|
metrics = results["metrics"]
|
||||||
|
profitable = metrics["total_return_pct"] > 0
|
||||||
|
return profitable, metrics
|
||||||
|
except Exception as e:
|
||||||
|
return False, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Signal validation (main entry point)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def validate_signal(instrument, signal, df, cfg, models=None):
|
||||||
|
"""
|
||||||
|
Main entry point — orchestrates all AI validation checks:
|
||||||
|
1. Build feature vector -> ensemble predict -> confidence + agreement
|
||||||
|
2. Run sanity checks
|
||||||
|
3. Run backtest on recent window
|
||||||
|
4. Combine into final decision
|
||||||
|
|
||||||
|
If confidence < threshold OR sanity fails OR ensemble disagrees -> REJECT.
|
||||||
|
|
||||||
|
Returns dict with: approved, confidence, rationale, checks.
|
||||||
|
"""
|
||||||
|
ai_cfg = cfg.get("ai", {})
|
||||||
|
threshold = ai_cfg.get("confidence_threshold", 0.85)
|
||||||
|
bt_window = ai_cfg.get("backtest_validation_window", 50)
|
||||||
|
strategy_cfg = cfg.get("strategy", {})
|
||||||
|
|
||||||
|
checks = {}
|
||||||
|
rationale_parts = []
|
||||||
|
|
||||||
|
# 1. Ensemble prediction
|
||||||
|
if models:
|
||||||
|
fv = build_feature_vector(df)
|
||||||
|
confidence, predictions, agreement = ensemble_predict(models, fv)
|
||||||
|
checks["ensemble_confidence"] = round(confidence, 4)
|
||||||
|
checks["ensemble_predictions"] = [round(p, 4) for p in predictions]
|
||||||
|
checks["ensemble_agreement"] = agreement
|
||||||
|
else:
|
||||||
|
confidence = 0.0
|
||||||
|
agreement = False
|
||||||
|
checks["ensemble_confidence"] = 0.0
|
||||||
|
checks["ensemble_agreement"] = False
|
||||||
|
rationale_parts.append("No ensemble models available")
|
||||||
|
|
||||||
|
# 2. Sanity checks
|
||||||
|
sanity_passed, sanity_reasons = sanity_checks(df, instrument, cfg)
|
||||||
|
checks["sanity_passed"] = sanity_passed
|
||||||
|
checks["sanity_reasons"] = sanity_reasons
|
||||||
|
if not sanity_passed:
|
||||||
|
rationale_parts.append(f"Sanity failed: {'; '.join(sanity_reasons)}")
|
||||||
|
|
||||||
|
# 3. Backtest recent window
|
||||||
|
bt_profitable, bt_metrics = backtest_recent_window(df, strategy_cfg, window=bt_window)
|
||||||
|
checks["backtest_profitable"] = bt_profitable
|
||||||
|
checks["backtest_metrics"] = bt_metrics
|
||||||
|
if not bt_profitable:
|
||||||
|
rationale_parts.append(f"Recent backtest unprofitable: {bt_metrics.get('total_return_pct', 'N/A')}%")
|
||||||
|
|
||||||
|
# 4. Final decision
|
||||||
|
approved = True
|
||||||
|
if confidence < threshold:
|
||||||
|
approved = False
|
||||||
|
rationale_parts.append(f"Confidence {confidence:.4f} < threshold {threshold}")
|
||||||
|
if not agreement and models:
|
||||||
|
approved = False
|
||||||
|
rationale_parts.append("Ensemble models disagree on direction")
|
||||||
|
if not sanity_passed:
|
||||||
|
approved = False
|
||||||
|
|
||||||
|
rationale = "; ".join(rationale_parts) if rationale_parts else "All checks passed"
|
||||||
|
|
||||||
|
decision = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"instrument": instrument,
|
||||||
|
"signal": signal,
|
||||||
|
"confidence": round(confidence, 4),
|
||||||
|
"approved": approved,
|
||||||
|
"ensemble_agreement": agreement if models else False,
|
||||||
|
"sanity_passed": sanity_passed,
|
||||||
|
"backtest_profitable": bt_profitable,
|
||||||
|
"rationale": rationale,
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
return decision
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Decision logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def log_ai_decision(decision_details):
|
||||||
|
"""
|
||||||
|
Append AI decision to logs/ai_decisions.csv.
|
||||||
|
Creates the file with headers if it doesn't exist.
|
||||||
|
"""
|
||||||
|
root = get_project_root()
|
||||||
|
logs_dir = root / "logs"
|
||||||
|
logs_dir.mkdir(exist_ok=True)
|
||||||
|
csv_path = logs_dir / "ai_decisions.csv"
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
"timestamp", "instrument", "signal", "confidence", "approved",
|
||||||
|
"ensemble_agreement", "sanity_passed", "backtest_profitable", "rationale",
|
||||||
|
]
|
||||||
|
|
||||||
|
row = {col: decision_details.get(col, "") for col in columns}
|
||||||
|
|
||||||
|
file_exists = csv_path.exists()
|
||||||
|
with open(csv_path, "a", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=columns)
|
||||||
|
if not file_exists:
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Standalone test
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Standalone test: train ensemble on Supabase data, run validation,
|
||||||
|
and print results.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from supabase import create_client
|
||||||
|
from backtester import fetch_candles_from_supabase
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("fx-quant AI Decision Wrapper — Standalone Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Supabase client
|
||||||
|
supabase_url = os.getenv("SUPABASE_URL")
|
||||||
|
supabase_key = os.getenv("SUPABASE_KEY")
|
||||||
|
if not supabase_url or not supabase_key:
|
||||||
|
raise SystemExit("Missing SUPABASE_URL or SUPABASE_KEY in config/.env")
|
||||||
|
sb = create_client(supabase_url, supabase_key)
|
||||||
|
table = cfg.get("supabase", {}).get("table", "fx_candles")
|
||||||
|
|
||||||
|
strategy_cfg = cfg["strategy"]
|
||||||
|
instruments = cfg["brokers"][0]["instruments"]
|
||||||
|
granularity = cfg["data"]["candle_granularities"][0]
|
||||||
|
|
||||||
|
for instrument in instruments:
|
||||||
|
print(f"\n--- {instrument} ---")
|
||||||
|
|
||||||
|
df = fetch_candles_from_supabase(instrument, granularity, sb, table)
|
||||||
|
if df.empty:
|
||||||
|
print(f" No data for {instrument}. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
df = generate_signals(df, strategy_cfg)
|
||||||
|
if df.empty:
|
||||||
|
print(f" No valid rows after signal generation. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Train ensemble
|
||||||
|
models, val_metrics = train_ensemble(df, strategy_cfg)
|
||||||
|
print(f" Validation metrics: {val_metrics}")
|
||||||
|
|
||||||
|
if not models:
|
||||||
|
print(" Skipping validation — no models trained.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build context
|
||||||
|
context = build_context(df, instrument, sb, cfg)
|
||||||
|
print(f" Context: {context}")
|
||||||
|
|
||||||
|
# Validate latest signal
|
||||||
|
latest_signal = int(df["signal"].iloc[-1])
|
||||||
|
decision = validate_signal(instrument, latest_signal, df, cfg, models=models)
|
||||||
|
|
||||||
|
print(f" Signal: {'LONG' if latest_signal == 1 else 'FLAT'}")
|
||||||
|
print(f" Approved: {decision['approved']}")
|
||||||
|
print(f" Confidence: {decision['confidence']:.4f}")
|
||||||
|
print(f" Agreement: {decision['ensemble_agreement']}")
|
||||||
|
print(f" Sanity passed: {decision['sanity_passed']}")
|
||||||
|
print(f" Backtest profitable: {decision['backtest_profitable']}")
|
||||||
|
print(f" Rationale: {decision['rationale']}")
|
||||||
|
|
||||||
|
# Log decision
|
||||||
|
log_ai_decision(decision)
|
||||||
|
print(f" Decision logged to logs/ai_decisions.csv")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Standalone test complete.")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+42
-4
@@ -6,7 +6,9 @@ Includes kill switch, position limits, and full order logging.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import csv
|
import csv
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ from supabase import create_client
|
|||||||
|
|
||||||
from config_loader import load_config, get_project_root
|
from config_loader import load_config, get_project_root
|
||||||
from backtester import fetch_candles_from_supabase, generate_signals
|
from backtester import fetch_candles_from_supabase, generate_signals
|
||||||
|
from ai_wrapper import train_ensemble, validate_signal, log_ai_decision
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -277,12 +280,15 @@ def place_order(instrument, units, side, cfg, price=None):
|
|||||||
# Signal execution
|
# Signal execution
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def execute_signals(signals_df, cfg):
|
def execute_signals(signals_df, cfg, ai_models=None):
|
||||||
"""
|
"""
|
||||||
Takes a DataFrame with signal column (from generate_signals).
|
Takes a DataFrame with signal column (from generate_signals).
|
||||||
Reads the latest signal per instrument, compares to current positions,
|
Reads the latest signal per instrument, compares to current positions,
|
||||||
and places orders for needed changes.
|
and places orders for needed changes.
|
||||||
|
|
||||||
|
If ai_models are provided, validates signals through the AI decision
|
||||||
|
wrapper before placing orders.
|
||||||
|
|
||||||
Returns list of order results.
|
Returns list of order results.
|
||||||
"""
|
"""
|
||||||
paper_mode = cfg.get("execution", {}).get("paper_mode", True)
|
paper_mode = cfg.get("execution", {}).get("paper_mode", True)
|
||||||
@@ -327,6 +333,14 @@ def execute_signals(signals_df, cfg):
|
|||||||
|
|
||||||
# Signal=1 means go long, signal=0 means go flat
|
# Signal=1 means go long, signal=0 means go flat
|
||||||
if signal == 1 and current_position == 0:
|
if signal == 1 and current_position == 0:
|
||||||
|
# AI validation before placing BUY order
|
||||||
|
if ai_models:
|
||||||
|
ai_decision = validate_signal(instrument, signal, signals_df, cfg, models=ai_models)
|
||||||
|
log_ai_decision(ai_decision)
|
||||||
|
if not ai_decision["approved"]:
|
||||||
|
print(f" AI REJECTED: confidence={ai_decision['confidence']:.2f}, {ai_decision['rationale']}")
|
||||||
|
return results
|
||||||
|
|
||||||
units = compute_units(balance, instrument, "BUY", cfg)
|
units = compute_units(balance, instrument, "BUY", cfg)
|
||||||
result = place_order(instrument, units, "BUY", cfg, price=close_price)
|
result = place_order(instrument, units, "BUY", cfg, price=close_price)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
@@ -418,8 +432,17 @@ def main():
|
|||||||
latest_signal = "LONG" if df["signal"].iloc[-1] == 1 else "FLAT"
|
latest_signal = "LONG" if df["signal"].iloc[-1] == 1 else "FLAT"
|
||||||
print(f" Latest signal: {latest_signal} (close={df['close'].iloc[-1]:.5f})")
|
print(f" Latest signal: {latest_signal} (close={df['close'].iloc[-1]:.5f})")
|
||||||
|
|
||||||
# Execute
|
# Train AI ensemble for this instrument
|
||||||
results = execute_signals(df, cfg)
|
ai_models = None
|
||||||
|
ai_cfg = cfg.get("ai", {})
|
||||||
|
if ai_cfg.get("model") == "local-ensemble":
|
||||||
|
print(f" Training AI ensemble for {instrument}...")
|
||||||
|
models_list, val_metrics = train_ensemble(df, strategy_cfg)
|
||||||
|
if models_list:
|
||||||
|
ai_models = models_list
|
||||||
|
|
||||||
|
# Execute with AI validation
|
||||||
|
results = execute_signals(df, cfg, ai_models=ai_models)
|
||||||
all_results.extend(results)
|
all_results.extend(results)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -432,4 +455,19 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
cfg = load_config()
|
||||||
|
interval = cfg.get("execution", {}).get("interval_seconds", 60)
|
||||||
|
|
||||||
|
# One-shot mode: pass --once to run a single iteration and exit
|
||||||
|
if "--once" in sys.argv:
|
||||||
|
main()
|
||||||
|
else:
|
||||||
|
print(f"Running on {interval}s loop. Press Ctrl+C to stop.\n")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
print(f"\nSleeping {interval}s until next run...\n")
|
||||||
|
time.sleep(interval)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down gracefully.")
|
||||||
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user