← back to Zero Code Trading Bot

assets/bot-template/safe_eval.py

99 lines

"""
safe_eval.py — a deliberately tiny, sandboxed expression evaluator.

Strategy rules ("price <= 0.08 and volume_usd >= 100000") are user/AI authored,
so we NEVER hand them to Python's built-in eval(). Instead we parse each rule to
an AST and walk only a whitelist of node types + variable names. Anything else
(function calls, attribute access, imports, dunder names) is refused.

This is the single most important safety boundary in the bot: a strategy file is
data, not code. Keep it that way.
"""

import ast
import operator

# Only these operators are allowed inside a rule expression.
_BIN_OPS = {
    ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
    ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
}
_CMP_OPS = {
    ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Lt: operator.lt,
    ast.LtE: operator.le, ast.Gt: operator.gt, ast.GtE: operator.ge,
}
_BOOL_OPS = {ast.And: all, ast.Or: any}
_UNARY_OPS = {ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Not: operator.not_}


class RuleError(ValueError):
    """A strategy rule that is malformed or references an unknown variable."""


def _eval_node(node, variables):
    if isinstance(node, ast.Expression):
        return _eval_node(node.body, variables)
    if isinstance(node, ast.BoolOp):
        fn = _BOOL_OPS[type(node.op)]
        return bool(fn(_eval_node(v, variables) for v in node.values))
    if isinstance(node, ast.BinOp):
        op = _BIN_OPS.get(type(node.op))
        if op is None:
            raise RuleError(f"operator not allowed: {type(node.op).__name__}")
        return op(_eval_node(node.left, variables), _eval_node(node.right, variables))
    if isinstance(node, ast.UnaryOp):
        op = _UNARY_OPS.get(type(node.op))
        if op is None:
            raise RuleError(f"unary operator not allowed: {type(node.op).__name__}")
        return op(_eval_node(node.operand, variables))
    if isinstance(node, ast.Compare):
        left = _eval_node(node.left, variables)
        for op_node, comparator in zip(node.ops, node.comparators):
            op = _CMP_OPS.get(type(op_node))
            if op is None:
                raise RuleError(f"comparison not allowed: {type(op_node).__name__}")
            right = _eval_node(comparator, variables)
            if not op(left, right):
                return False
            left = right
        return True
    if isinstance(node, ast.Name):
        if node.id in ("True", "False"):  # py<3.8 safety; harmless otherwise
            return node.id == "True"
        if node.id not in variables:
            raise RuleError(f"unknown variable: {node.id!r}")
        return variables[node.id]
    if isinstance(node, ast.Constant):  # numbers, bools, strings
        return node.value
    raise RuleError(f"expression element not allowed: {type(node).__name__}")


def eval_rule(expr, variables):
    """Evaluate a strategy rule string against a dict of market variables."""
    try:
        tree = ast.parse(expr, mode="eval")
    except SyntaxError as e:
        raise RuleError(f"cannot parse rule {expr!r}: {e}") from e
    return _eval_node(tree, variables)


def check_rule(expr, allowed_vars):
    """Static check: parses the rule and confirms every name is a known variable.
    Returns (ok, message). Used by the strategy validator before a bot ever runs."""
    dummy = {name: 0 for name in allowed_vars}
    try:
        eval_rule(expr, dummy)
        return True, "ok"
    except RuleError as e:
        return False, str(e)


# The variables a rule may reference. venue_adapter.py populates these per market.
MARKET_VARS = (
    "price",            # current YES price, 0..1
    "volume_usd",       # lifetime USD volume
    "liquidity_usd",    # order-book liquidity
    "spread",           # best ask - best bid, 0..1
    "days_to_resolve",  # days until market resolution
)