"""Validators for MT5 operations and parameters."""
import MetaTrader5 as mt5
import inspect
import logging
from typing import Tuple, Optional, Dict, Any, List
from functools import lru_cache
import difflib
logger = logging.getLogger(__name__)
# ============================================================================
# CACHING
# ============================================================================
def get_symbol_info_cached(symbol: str) -> Optional[Any]:
"""Get symbol info with caching (doesn't cache None to avoid stale failures)."""
# Check if already in cache
cache_key = symbol
if hasattr(get_symbol_info_cached, "_cache"):
if cache_key in get_symbol_info_cached._cache:
return get_symbol_info_cached._cache[cache_key]
else:
get_symbol_info_cached._cache = {}
# Query MT5
info = mt5.symbol_info(symbol)
# Only cache successful results (not None)
if info is not None:
get_symbol_info_cached._cache[cache_key] = info
return info
@lru_cache(maxsize=32)
def get_all_symbols_cached() -> List[str]:
"""Get all symbol names with caching."""
symbols = mt5.symbols_get()
if symbols:
return [s.name for s in symbols]
return []
def clear_symbol_cache():
"""Clear symbol cache (call if symbols updated)."""
if hasattr(get_symbol_info_cached, "_cache"):
get_symbol_info_cached._cache.clear()
get_all_symbols_cached.cache_clear()
# ============================================================================
# SYMBOL VALIDATION
# ============================================================================
def validate_symbol(
symbol: str,
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
"""
Validate symbol exists in MT5 and auto-select it in Market Watch.
This function ensures the symbol is available for querying by:
1. Attempting to select (enable) the symbol in Market Watch
2. Validating symbol info is retrievable
3. Suggesting similar symbols if not found
Returns:
(is_valid, error_message, correction_dict)
"""
symbol = symbol.strip().upper()
# First, try to select/enable the symbol in Market Watch
# This is required for symbols not currently visible in Market Watch
try:
select_result = mt5.symbol_select(symbol, True)
if select_result:
# Clear cache since we just enabled a new symbol
if hasattr(get_symbol_info_cached, "_cache"):
get_symbol_info_cached._cache.pop(symbol, None)
except Exception:
pass # Continue even if select fails - symbol_info will catch actual errors
# Check if symbol exists
symbol_info = get_symbol_info_cached(symbol)
if symbol_info is None:
# Find similar symbols
all_symbols = get_all_symbols_cached()
similar = difflib.get_close_matches(symbol, all_symbols, n=3, cutoff=0.6)
error_msg = f"Symbol '{symbol}' not found."
if similar:
suggestion = f"Did you mean: {', '.join(similar)}?"
corrected = {"symbol": similar[0]}
else:
suggestion = "Use 'symbols_get' operation to list available symbols."
corrected = None
return (
False,
error_msg,
{"suggestion": suggestion, "corrected_params": corrected},
)
return True, None, None
# ============================================================================
# PARAMETER VALIDATION
# ============================================================================
@lru_cache(maxsize=64)
def get_function_signature(operation_name: str) -> Optional[inspect.Signature]:
"""Get MT5 function signature with caching."""
try:
func = getattr(mt5, operation_name, None)
if func and callable(func):
return inspect.signature(func)
except Exception as e:
logger.warning(f"Failed to get signature for {operation_name}: {e}")
return None
def validate_operation_parameters(
operation_name: str, provided_params: Dict[str, Any]
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
"""
Validate parameters for MT5 operation.
Returns:
(is_valid, error_message, suggestion_dict)
"""
sig = get_function_signature(operation_name)
if sig is None:
return True, None, None # Can't validate, allow operation to fail naturally
try:
# Get required parameters
required_params = []
optional_params = []
for param_name, param in sig.parameters.items():
if param.default == inspect.Parameter.empty and param_name != "self":
required_params.append(param_name)
else:
optional_params.append(param_name)
# Check for missing required parameters
missing = [p for p in required_params if p not in provided_params]
if missing:
error_msg = f"Missing required parameters: {', '.join(missing)}"
suggestion = f"Add: {', '.join([f'{p}=<value>' for p in missing])}"
return False, error_msg, {"suggestion": suggestion}
# Check for unknown parameters
all_params = required_params + optional_params
unknown = [p for p in provided_params if p not in all_params]
if unknown:
error_msg = f"Unknown parameters: {', '.join(unknown)}"
suggestion = f"Valid parameters: {', '.join(all_params)}"
return False, error_msg, {"suggestion": suggestion}
return True, None, None
except Exception as e:
logger.error(f"Parameter validation error for {operation_name}: {e}")
return True, None, None # Allow operation to proceed
# ============================================================================
# TIMEFRAME CONVERSION
# ============================================================================
TIMEFRAME_MAP = {
"M1": mt5.TIMEFRAME_M1,
"M2": mt5.TIMEFRAME_M2,
"M3": mt5.TIMEFRAME_M3,
"M4": mt5.TIMEFRAME_M4,
"M5": mt5.TIMEFRAME_M5,
"M6": mt5.TIMEFRAME_M6,
"M10": mt5.TIMEFRAME_M10,
"M12": mt5.TIMEFRAME_M12,
"M15": mt5.TIMEFRAME_M15,
"M20": mt5.TIMEFRAME_M20,
"M30": mt5.TIMEFRAME_M30,
"H1": mt5.TIMEFRAME_H1,
"H2": mt5.TIMEFRAME_H2,
"H3": mt5.TIMEFRAME_H3,
"H4": mt5.TIMEFRAME_H4,
"H6": mt5.TIMEFRAME_H6,
"H8": mt5.TIMEFRAME_H8,
"H12": mt5.TIMEFRAME_H12,
"D1": mt5.TIMEFRAME_D1,
"W1": mt5.TIMEFRAME_W1,
"MN1": mt5.TIMEFRAME_MN1,
}
def convert_timeframe(timeframe_str: str) -> int:
"""Convert timeframe string to MT5 constant."""
tf = timeframe_str.upper()
if tf not in TIMEFRAME_MAP:
raise ValueError(
f"Invalid timeframe: '{timeframe_str}'. " f"Valid: {', '.join(TIMEFRAME_MAP.keys())}"
)
return TIMEFRAME_MAP[tf]
# ============================================================================
# ORDER TYPE CONVERSION
# ============================================================================
ORDER_TYPE_MAP = {
"buy": mt5.ORDER_TYPE_BUY,
"sell": mt5.ORDER_TYPE_SELL,
}
def convert_order_type(order_type_str: str) -> int:
"""Convert order type string to MT5 constant."""
ot = order_type_str.lower()
if ot not in ORDER_TYPE_MAP:
raise ValueError(f"Invalid order_type: '{order_type_str}'. Valid: buy, sell")
return ORDER_TYPE_MAP[ot]
# ============================================================================
# VOLUME VALIDATION
# ============================================================================
def validate_and_adjust_volume(symbol: str, volume: float) -> Tuple[float, Optional[str]]:
"""
Validate and adjust volume to symbol constraints.
Returns:
(adjusted_volume, warning_message)
"""
symbol_info = get_symbol_info_cached(symbol)
if symbol_info is None:
return volume, None
vol_min = symbol_info.volume_min
vol_max = symbol_info.volume_max
vol_step = symbol_info.volume_step
original = volume
# Clamp to min/max
volume = max(vol_min, min(vol_max, volume))
# Round to nearest step
steps = round(volume / vol_step)
volume = steps * vol_step
volume = max(vol_min, min(vol_max, volume))
warning = None
if abs(volume - original) > 1e-9:
warning = (
"Volume adjusted from "
f"{original} to {volume} (min={vol_min}, max={vol_max}, step={vol_step})"
)
return volume, warning
# ============================================================================
# INDICATOR VALIDATION
# ============================================================================
def validate_indicator_data_requirements(
indicator_function: str, period: int, bar_count: int
) -> Tuple[bool, Optional[str]]:
"""
Validate sufficient data for indicator calculation.
Returns:
(is_valid, error_message_with_suggestion)
"""
# Extract indicator type from function path
if "rsi" in indicator_function.lower():
required = period + 10
elif "macd" in indicator_function.lower():
required = 35 # MACD typically needs 26 + buffer
elif "bollinger" in indicator_function.lower():
required = period + 10
elif "sma" in indicator_function.lower() or "ema" in indicator_function.lower():
required = period + 5
elif "atr" in indicator_function.lower():
required = period + 5
else:
required = max(50, period + 10) # Conservative default
if bar_count < required:
message = (
f"Need at least {required} bars for {indicator_function}, got {bar_count}. "
f"Try: count={required}"
)
return (False, message)
return True, None
# ============================================================================
# TA FUNCTION VALIDATION
# ============================================================================
def validate_ta_function(function_path: str) -> Tuple[bool, Optional[str]]:
"""
Validate TA-Lib function path.
Returns:
(is_valid, error_message)
"""
if not function_path.startswith("ta."):
return False, f"Function path must start with 'ta.', got: '{function_path}'"
parts = function_path.split(".")
if len(parts) != 3:
return False, ("Function path must be 'ta.<module>.<function>', got: " f"'{function_path}'")
try:
import ta
module_name = parts[1]
func_name = parts[2]
# Check if module exists
if not hasattr(ta, module_name):
available = "momentum, trend, volatility, volume, others"
return (
False,
f"TA-Lib module '{module_name}' not found. Available: {available}",
)
module = getattr(ta, module_name)
# Check if function exists
if not hasattr(module, func_name):
return False, f"Function '{func_name}' not found in ta.{module_name}"
return True, None
except Exception as e:
return False, f"Error validating TA function: {str(e)}"