mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-01 04:47:43 +00:00
b1f3a919bf
New strategy (pivot_retest_engulfing) that enters long/short trades at pivot level retests confirmed by SMA 50 alignment and engulfing candle patterns. Uses ATR-based stop loss with two take-profit levels — at TP1 half the position closes and SL moves to breakeven, at TP2 the rest closes. - data_engine: add detect_engulfing() for bullish/bearish pattern detection - backtester: add generate_signals_pivot_retest(), run_backtest_dual_tp(), update signal dispatcher and metrics for dual-TP trade format - order_executor: support signal=-1 (SHORT), attach SL/TP levels - config: switch to pivot_retest_engulfing with default params - chart_trades: new mplfinance script to visualize entries on candlesticks - README: rewrite with full setup guide, project structure, strategy docs - requirements.txt: make portable (remove conda file:// paths), add mplfinance - .env.example: add template for secrets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
534 lines
18 KiB
Python
534 lines
18 KiB
Python
# src/order_executor.py
|
|
"""
|
|
Paper-first order execution engine for fx-quant.
|
|
Supports paper mode (simulated fills) and live mode (OANDA v20 market orders).
|
|
Includes kill switch, position limits, and full order logging.
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import csv
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import requests
|
|
from supabase import create_client
|
|
|
|
from config_loader import load_config, get_project_root
|
|
from backtester import fetch_candles_from_supabase, generate_signals
|
|
from ai_wrapper import train_ensemble, validate_signal, log_ai_decision
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OANDA helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_oanda_base_url():
|
|
"""Return the OANDA API base URL based on OANDA_ENV."""
|
|
env = os.getenv("OANDA_ENV", "practice").strip()
|
|
if env == "practice":
|
|
return "https://api-fxpractice.oanda.com"
|
|
return "https://api-fxtrade.oanda.com"
|
|
|
|
|
|
def _oanda_headers():
|
|
"""Return auth headers for OANDA API calls."""
|
|
api_key = os.getenv("OANDA_API_KEY")
|
|
return {
|
|
"Authorization": f"Bearer {api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Kill switch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def check_kill_switch():
|
|
"""
|
|
Check for STOP_ALL_TRADING file in project root.
|
|
Returns True if trading should be halted.
|
|
"""
|
|
kill_file = get_project_root() / "STOP_ALL_TRADING"
|
|
return kill_file.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Account & trade queries
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_account_summary():
|
|
"""
|
|
GET /v3/accounts/{ACCOUNT_ID}/summary
|
|
Returns dict with balance, NAV, and open position count.
|
|
"""
|
|
account_id = os.getenv("OANDA_ACCOUNT_ID")
|
|
base = get_oanda_base_url()
|
|
url = f"{base}/v3/accounts/{account_id}/summary"
|
|
|
|
r = requests.get(url, headers=_oanda_headers())
|
|
r.raise_for_status()
|
|
acct = r.json()["account"]
|
|
return {
|
|
"balance": float(acct["balance"]),
|
|
"NAV": float(acct["NAV"]),
|
|
"open_position_count": int(acct["openPositionCount"]),
|
|
"open_trade_count": int(acct["openTradeCount"]),
|
|
}
|
|
|
|
|
|
def get_open_trades():
|
|
"""
|
|
GET /v3/accounts/{ACCOUNT_ID}/openTrades
|
|
Returns list of open trade dicts.
|
|
"""
|
|
account_id = os.getenv("OANDA_ACCOUNT_ID")
|
|
base = get_oanda_base_url()
|
|
url = f"{base}/v3/accounts/{account_id}/openTrades"
|
|
|
|
r = requests.get(url, headers=_oanda_headers())
|
|
r.raise_for_status()
|
|
return r.json().get("trades", [])
|
|
|
|
|
|
def close_all_trades():
|
|
"""
|
|
Close every open trade. Used when kill switch is activated.
|
|
"""
|
|
account_id = os.getenv("OANDA_ACCOUNT_ID")
|
|
base = get_oanda_base_url()
|
|
trades = get_open_trades()
|
|
|
|
if not trades:
|
|
print("No open trades to close.")
|
|
return
|
|
|
|
for trade in trades:
|
|
trade_id = trade["id"]
|
|
url = f"{base}/v3/accounts/{account_id}/trades/{trade_id}/close"
|
|
r = requests.put(url, headers=_oanda_headers())
|
|
if r.status_code == 200:
|
|
print(f" Closed trade {trade_id} ({trade['instrument']})")
|
|
else:
|
|
print(f" Failed to close trade {trade_id}: {r.status_code} {r.text}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit sizing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def compute_units(balance, instrument, side, cfg):
|
|
"""
|
|
Calculate order unit count from account balance and config.
|
|
|
|
In paper mode uses trade_size_pct_of_equity.
|
|
In live mode uses canary_size_pct (conservative).
|
|
Returns positive int for BUY, negative int for SELL.
|
|
"""
|
|
paper_mode = cfg.get("execution", {}).get("paper_mode", True)
|
|
|
|
if paper_mode:
|
|
pct = cfg["strategy"]["trade_size_pct_of_equity"]
|
|
else:
|
|
pct = cfg["execution"]["canary_size_pct"]
|
|
|
|
units = int(balance * pct)
|
|
if units < 1:
|
|
units = 1
|
|
|
|
if side == "SELL":
|
|
units = -units
|
|
|
|
return units
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Order logging
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def log_order(order_details):
|
|
"""
|
|
Append order to logs/order_log.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 / "order_log.csv"
|
|
|
|
columns = [
|
|
"timestamp", "instrument", "side", "units", "price",
|
|
"order_type", "mode", "status", "response",
|
|
]
|
|
|
|
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(order_details)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Place order
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def place_order(instrument, units, side, cfg, price=None):
|
|
"""
|
|
Place an order (paper or live).
|
|
|
|
Paper mode: simulate the order, log it, do NOT call OANDA API.
|
|
Live mode: POST market order to OANDA, log the response.
|
|
|
|
Returns order response dict.
|
|
"""
|
|
paper_mode = cfg.get("execution", {}).get("paper_mode", True)
|
|
max_positions = cfg.get("execution", {}).get("max_positions", 3)
|
|
mode = "paper" if paper_mode else "live"
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
# Kill switch check before every order
|
|
if check_kill_switch():
|
|
print(f" KILL SWITCH active — order blocked: {side} {units} {instrument}")
|
|
log_order({
|
|
"timestamp": now,
|
|
"instrument": instrument,
|
|
"side": side,
|
|
"units": units,
|
|
"price": price or "",
|
|
"order_type": "MARKET",
|
|
"mode": mode,
|
|
"status": "BLOCKED_KILL_SWITCH",
|
|
"response": "Kill switch file present",
|
|
})
|
|
return {"status": "BLOCKED_KILL_SWITCH"}
|
|
|
|
# Max positions check (live mode only — query open trades)
|
|
if not paper_mode:
|
|
try:
|
|
open_trades = get_open_trades()
|
|
if len(open_trades) >= max_positions and side == "BUY":
|
|
print(f" Max positions ({max_positions}) reached — order blocked.")
|
|
log_order({
|
|
"timestamp": now,
|
|
"instrument": instrument,
|
|
"side": side,
|
|
"units": units,
|
|
"price": price or "",
|
|
"order_type": "MARKET",
|
|
"mode": mode,
|
|
"status": "BLOCKED_MAX_POS",
|
|
"response": f"Open trades: {len(open_trades)}",
|
|
})
|
|
return {"status": "BLOCKED_MAX_POS"}
|
|
except Exception as e:
|
|
print(f" Error checking open trades: {e}")
|
|
|
|
# --- Paper mode ---
|
|
if paper_mode:
|
|
print(f" [PAPER] {side} {abs(units)} {instrument} @ {price or 'market'}")
|
|
log_order({
|
|
"timestamp": now,
|
|
"instrument": instrument,
|
|
"side": side,
|
|
"units": units,
|
|
"price": price or "",
|
|
"order_type": "MARKET",
|
|
"mode": "paper",
|
|
"status": "FILLED",
|
|
"response": "Paper fill simulated",
|
|
})
|
|
return {"status": "FILLED", "mode": "paper", "units": units, "price": price}
|
|
|
|
# --- Live mode ---
|
|
account_id = os.getenv("OANDA_ACCOUNT_ID")
|
|
base = get_oanda_base_url()
|
|
url = f"{base}/v3/accounts/{account_id}/orders"
|
|
|
|
order_body = {
|
|
"order": {
|
|
"type": "MARKET",
|
|
"instrument": instrument,
|
|
"units": str(units),
|
|
"timeInForce": "FOK",
|
|
"positionFill": "DEFAULT",
|
|
}
|
|
}
|
|
|
|
r = requests.post(url, headers=_oanda_headers(), json=order_body)
|
|
resp_data = r.json()
|
|
status = "FILLED" if r.status_code == 201 else f"ERROR_{r.status_code}"
|
|
|
|
print(f" [LIVE] {side} {abs(units)} {instrument} -> {status}")
|
|
log_order({
|
|
"timestamp": now,
|
|
"instrument": instrument,
|
|
"side": side,
|
|
"units": units,
|
|
"price": resp_data.get("orderFillTransaction", {}).get("price", ""),
|
|
"order_type": "MARKET",
|
|
"mode": "live",
|
|
"status": status,
|
|
"response": str(resp_data),
|
|
})
|
|
return {"status": status, "mode": "live", "response": resp_data}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Signal execution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def execute_signals(signals_df, cfg, ai_models=None):
|
|
"""
|
|
Takes a DataFrame with signal column (from generate_signals).
|
|
Reads the latest signal per instrument, compares to current positions,
|
|
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.
|
|
"""
|
|
paper_mode = cfg.get("execution", {}).get("paper_mode", True)
|
|
max_positions = cfg.get("execution", {}).get("max_positions", 3)
|
|
|
|
# Get account balance for sizing
|
|
if paper_mode:
|
|
balance = 10_000.0 # default paper balance
|
|
try:
|
|
summary = get_account_summary()
|
|
balance = summary["balance"]
|
|
print(f"Account balance: {balance:,.2f}")
|
|
except Exception:
|
|
print(f"Using default paper balance: {balance:,.2f}")
|
|
else:
|
|
summary = get_account_summary()
|
|
balance = summary["balance"]
|
|
print(f"Account balance: {balance:,.2f}")
|
|
|
|
# Get latest signal row
|
|
if signals_df.empty:
|
|
print("No signals to execute.")
|
|
return []
|
|
|
|
latest = signals_df.iloc[-1]
|
|
instrument = latest.get("instrument", signals_df.attrs.get("instrument", "UNKNOWN"))
|
|
signal = int(latest["signal"])
|
|
close_price = float(latest["close"])
|
|
|
|
# Determine current position for this instrument
|
|
# Track position state for paper mode using a module-level dict
|
|
current_position = 0
|
|
if not paper_mode:
|
|
try:
|
|
open_trades = get_open_trades()
|
|
for trade in open_trades:
|
|
if trade["instrument"] == instrument:
|
|
current_position = 1 if float(trade["currentUnits"]) > 0 else -1
|
|
except Exception as e:
|
|
print(f" Error checking positions: {e}")
|
|
|
|
results = []
|
|
|
|
# Extract per-bar SL/TP levels if available (pivot_retest_engulfing strategy)
|
|
sl_price = latest.get("sl_price") if hasattr(latest, "get") else getattr(latest, "sl_price", None)
|
|
tp1_price = latest.get("tp1_price") if hasattr(latest, "get") else getattr(latest, "tp1_price", None)
|
|
tp2_price = latest.get("tp2_price") if hasattr(latest, "get") else getattr(latest, "tp2_price", None)
|
|
|
|
# Coerce NaN to None
|
|
if sl_price is not None and pd.isna(sl_price):
|
|
sl_price = None
|
|
if tp1_price is not None and pd.isna(tp1_price):
|
|
tp1_price = None
|
|
if tp2_price is not None and pd.isna(tp2_price):
|
|
tp2_price = None
|
|
|
|
# Signal=1 means go long, signal=-1 means go short, signal=0 means go flat
|
|
if signal == 1 and current_position <= 0:
|
|
# Close any existing short first
|
|
if current_position == -1:
|
|
units = compute_units(balance, instrument, "BUY", cfg)
|
|
result = place_order(instrument, units, "BUY", cfg, price=close_price)
|
|
results.append(result)
|
|
|
|
# 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)
|
|
result = place_order(instrument, units, "BUY", cfg, price=close_price)
|
|
results.append(result)
|
|
|
|
if sl_price is not None:
|
|
print(f" SL={sl_price:.5f} TP1={tp1_price:.5f} TP2={tp2_price:.5f}")
|
|
|
|
elif signal == -1 and current_position >= 0:
|
|
# Close any existing long first
|
|
if current_position == 1:
|
|
units = compute_units(balance, instrument, "SELL", cfg)
|
|
result = place_order(instrument, units, "SELL", cfg, price=close_price)
|
|
results.append(result)
|
|
|
|
# AI validation before placing SHORT 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, "SELL", cfg)
|
|
result = place_order(instrument, units, "SELL", cfg, price=close_price)
|
|
results.append(result)
|
|
|
|
if sl_price is not None:
|
|
print(f" SL={sl_price:.5f} TP1={tp1_price:.5f} TP2={tp2_price:.5f}")
|
|
|
|
elif signal == 0 and current_position != 0:
|
|
side = "SELL" if current_position == 1 else "BUY"
|
|
units = compute_units(balance, instrument, side, cfg)
|
|
result = place_order(instrument, units, side, cfg, price=close_price)
|
|
results.append(result)
|
|
else:
|
|
action = {1: "LONG", -1: "SHORT", 0: "FLAT"}.get(signal, "FLAT")
|
|
print(f" {instrument}: signal={action}, position matches — no action.")
|
|
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
cfg = load_config()
|
|
|
|
print("=" * 60)
|
|
print("fx-quant Order Executor")
|
|
print("=" * 60)
|
|
|
|
paper_mode = cfg.get("execution", {}).get("paper_mode", True)
|
|
mode_label = "PAPER" if paper_mode else "LIVE"
|
|
print(f"Mode: {mode_label}")
|
|
|
|
# Kill switch check at startup
|
|
if check_kill_switch():
|
|
print("\nKILL SWITCH ACTIVE — STOP_ALL_TRADING file detected.")
|
|
if not paper_mode:
|
|
print("Closing all open trades...")
|
|
close_all_trades()
|
|
log_order({
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"instrument": "ALL",
|
|
"side": "CLOSE_ALL",
|
|
"units": 0,
|
|
"price": "",
|
|
"order_type": "KILL_SWITCH",
|
|
"mode": mode_label.lower(),
|
|
"status": "HALTED",
|
|
"response": "Kill switch activated at startup",
|
|
})
|
|
print("Logged kill switch event. Exiting.")
|
|
return
|
|
|
|
# Supabase client for fetching candles
|
|
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] # use finest granularity
|
|
|
|
print(f"Strategy: {strategy_cfg['rule']}")
|
|
print(f"Instruments: {instruments}")
|
|
print(f"Granularity: {granularity}")
|
|
print()
|
|
|
|
all_results = []
|
|
|
|
for instrument in instruments:
|
|
print(f"--- {instrument} ---")
|
|
|
|
# Fetch latest candles from Supabase
|
|
df = fetch_candles_from_supabase(instrument, granularity, sb, table)
|
|
if df.empty:
|
|
print(f" No data for {instrument}. Skipping.\n")
|
|
continue
|
|
|
|
# Generate signals
|
|
df = generate_signals(df, strategy_cfg)
|
|
if df.empty:
|
|
print(f" No valid rows after signal generation. Skipping.\n")
|
|
continue
|
|
|
|
# Attach instrument name for execute_signals
|
|
df.attrs["instrument"] = instrument
|
|
if "instrument" not in df.columns:
|
|
df["instrument"] = instrument
|
|
|
|
sig_val = int(df["signal"].iloc[-1])
|
|
latest_signal = {1: "LONG", -1: "SHORT", 0: "FLAT"}.get(sig_val, "FLAT")
|
|
print(f" Latest signal: {latest_signal} (close={df['close'].iloc[-1]:.5f})")
|
|
|
|
# Train AI ensemble for this instrument
|
|
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)
|
|
print()
|
|
|
|
# Summary
|
|
print("=" * 60)
|
|
print(f"Execution complete. Orders processed: {len(all_results)}")
|
|
for r in all_results:
|
|
print(f" {r}")
|
|
print("=" * 60)
|
|
|
|
|
|
if __name__ == "__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:
|
|
# Check for config reload signal from dashboard
|
|
reload_signal = get_project_root() / "RELOAD_CONFIG"
|
|
if reload_signal.exists():
|
|
print("\n*** CONFIG RELOAD REQUESTED ***")
|
|
try:
|
|
reload_signal.unlink()
|
|
except OSError:
|
|
pass
|
|
cfg = load_config()
|
|
interval = cfg.get("execution", {}).get("interval_seconds", 60)
|
|
print(f"Config reloaded. Interval now {interval}s.\n")
|
|
|
|
main()
|
|
print(f"\nSleeping {interval}s until next run...\n")
|
|
time.sleep(interval)
|
|
except KeyboardInterrupt:
|
|
print("\nShutting down gracefully.")
|
|
break
|