← Engineering notes

The ampersand that silently rewrites your entry rule

In Python and pandas, `&` binds tighter than every comparison, so `a > 1 & b > 2` parses as a chained comparison against a bitwise mask — here are the parse trees, the arithmetic, and the two ways it hides.

· 10 min read

A rule that crashes is cheap. You see it, you fix it, you lose an afternoon. The expensive ones are the rules that parse, run, produce a boolean column of the right length, and mean something other than what you wrote.

& is the canonical way to get one in pandas, and any rule-string DSL that borrows Python's parser inherits the problem whole. Ours does: konis_core/strategy/expression.py turns a rule string into a pandas Series by calling ast.parse(source, mode="eval") and walking the resulting tree against an explicit node allowlist (expression.py:214-224). There is no eval() and no exec() anywhere in it. But the tree is Python's tree, so Python's precedence is the contract, whatever the author had in mind.

The precedence that does it

The relevant slice of Python's operator table, tightest first:

**
* /
+ -
<< >>
&
^
|
< <= > >= != ==
not x
and
or

& sits five rows tighter than the comparisons. and sits two rows looser. The two operators people reach for to mean both of these things are true live on opposite sides of the comparison operators, and only one of them is on the side you assumed.

Three spellings, three trees

All output below is real, from Python 3.12.7 with pandas 2.3.2 and numpy 2.2.6. ctx=Load() trimmed for width.

>>> import ast
>>> ast.dump(ast.parse("a > 1 & b > 2", mode="eval").body)
Compare(left=Name(id='a'),
        ops=[Gt(), Gt()],
        comparators=[BinOp(left=Constant(value=1), op=BitAnd(), right=Name(id='b')),
                     Constant(value=2)])

>>> ast.dump(ast.parse("(a > 1) & (b > 2)", mode="eval").body)
BinOp(left=Compare(left=Name(id='a'), ops=[Gt()], comparators=[Constant(value=1)]),
      op=BitAnd(),
      right=Compare(left=Name(id='b'), ops=[Gt()], comparators=[Constant(value=2)]))

>>> ast.dump(ast.parse("a > 1 and b > 2", mode="eval").body)
BoolOp(op=And(),
       values=[Compare(left=Name(id='a'), ops=[Gt()], comparators=[Constant(value=1)]),
               Compare(left=Name(id='b'), ops=[Gt()], comparators=[Constant(value=2)])])

The first tree is the interesting one. It is one Compare node with two Gt ops — the same construct as 0 < x < 10. 1 & b was pulled inside it as the middle term. Python expands a chained comparison by duplicating that middle term and joining with and:

a > 1 & b > 2     is     (a > (1 & b)) and ((1 & b) > 2)

Not one comparison against a combined condition. Two comparisons against a bitwise mask.

One row of numbers through both readings

Take the rule an author meant to write — adx > 30 and rsi > 25 — on a bar where adx = 28 and rsi = 27. ADX is under its threshold, so the intended answer is False.

Now the & spelling, step by step:

30 & 27        ->  26          # 11110 & 11011 = 11010
adx > 26       ->  28 > 26     ->  True
26 > 25        ->  True
True and True  ->  True
>>> adx, rsi = 28, 27
>>> (adx > 30) and (rsi > 25)     # intended
False
>>> adx > 30 & rsi > 25           # as written
True

The rule fires on a bar its author excluded, and nothing anywhere reports an error.

Two structural facts make this worse than a one-off coincidence:

The threshold stops being the threshold. 30 & n cannot exceed 30 and cannot be odd. Over n in 0..255 the full set of values it takes is [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30]. Your written threshold of 30 has become some even number at or below 30, selected by the low bits of the other column.

With a small threshold the rule is simply dead. 1 & n is 0 or 1 for every integer — negatives included. So (1 & b) > 2 is False on every row, forever, and a > 1 & b > 2 can never be True. A rule that parses, validates, evaluates, and is incapable of firing.

Hiding place 1: integer columns

All of the above needs 1 & b to be legal in the first place. On a float column pandas refuses:

TypeError: Cannot perform 'rand_' with a dtyped [float64] array and scalar of type [bool]

Most indicator outputs are float, which is why this bug has a reputation for being loud. Integer columns have no such protection — a volume column read as int64, a count, a flag, anything you cast yourself. There the bitwise AND is perfectly well defined, returns a plausible-looking number, and the comparison against it returns a perfectly ordinary boolean column.

Note also that the error, when you do get one, is about rand_ and dtypes. It never says precedence. It is very easy to fix that message by casting a column and land straight in the silent case.

Hiding place 2: the truth value, which is shape dependent

The chained form ends in and, and and calls bool() on its left operand. On a Series that is the famous ambiguity error. It is widely believed to be a reliable backstop. Here is what our measurement found — pandas 2.3.2, numpy 2.2.6:

left operand length a > 1 & b > 2
pd.Series int64 0 ValueError: truth value of a Series is ambiguous
pd.Series int64 1 ValueError: truth value of a Series is ambiguous
pd.Series int64 3 ValueError: truth value of a Series is ambiguous
pd.Series float64 1 or 3 TypeError: cannot perform rand_
np.ndarray int 1 array([False]) — no error
np.ndarray int 3 ValueError: truth value of an array with more than one element is ambiguous
np.ndarray 0-d np.False_ — no error

So the shape dependence is real, but not where the folklore puts it. On a Series in this version it raises at every length, including the length-1 case that used to slip through when bool() on a one-element Series still worked. On a plain numpy array it is alive and well: one element passes silently, more than one raises. .values, .to_numpy(), a scalar reduction, a single-row frame in a unit test — each of those is a path where the backstop is not there, and the difference between a loud failure and a wrong number is the shape of whatever you happened to feed it.

What the module does about it

Both spellings are accepted, and both compute the same thing. _BINOPS maps ast.BitAnd/ast.BitOr to operator.and_/operator.or_ (expression.py:40-48), and visit_BoolOp maps and/or to those same two functions (expression.py:169-174). The operators are not the difference. The only difference between the spellings is where the parser puts the parentheses.

Both are accepted because both habits walk in the door. People who have written pandas reach for & without thinking; people writing a YAML document read and back as a sentence. Refusing either would mostly teach authors that the language is fussy, not that it is subtle. The module docstring (expression.py:18-21) tells authors to prefer the words, and the reason is exactly the table above: and and or are looser than comparison, so the word form groups the way the line reads. & and | are tighter, so they do not.

There is one guard. visit_Compare refuses any chain outright:

if len(node.ops) != 1:
    raise ExpressionError(
        "chained comparison (a < b < c) is ambiguous on a column; "
        "write it as (a < b) and (b < c)"
    )

(expression.py:176-181.) So the naked a > 1 & b > 2 cannot run here — it is rejected with that message.

Worth being precise about when. That check lives in the evaluator, and the loader does not evaluate: _check_expression calls referenced_names, which parses and collects column names only (loader.py:141-153, expression.py:227-243). Unknown columns are caught at upload. A chained comparison is caught the first time the expression is evaluated — in the preview or the backtest — via the ExpressionError wrapping in runtime.py:149-150 and 171-173. Earlier than live, later than parse.

The one that still gets through

Half-parenthesise it and the chain disappears, so the guard has nothing to catch:

(rsi < 30) | rsi > 70
Compare(left=BinOp(left=Compare(left=Name(id='rsi'), ops=[Lt()], comparators=[Constant(value=30)]),
                   op=BitOr(),
                   right=Name(id='rsi')),
        ops=[Gt()],
        comparators=[Constant(value=70)])

One Compare, one op. It passes. Measured on rsi = [12, 55, 88], through evaluate():

(rsi < 30) | (rsi > 70)   ->  [True, False, True]
rsi < 30 or rsi > 70      ->  [True, False, True]
(rsi < 30) | rsi > 70     ->  [False, False, False]

The mechanism: (rsi < 30) | rsi is a bool Series OR'd with a numeric Series, which pandas resolves to the truthiness of each — [True, True, True] for both int and float dtype here — and True > 70 is False on every bar. Column dtype does not rescue this one. A rule that was meant to catch both extremes catches nothing, on every bar, in silence.

Downstream, nothing objects either. _as_bool in runtime.py:60-67 accepts a numeric result and casts it — series.fillna(False).astype(bool) — which is the right behaviour for the ordinary case and also means an expression that quietly turned into arithmetic still hands back a well-formed boolean column.

Showing the grouping back

The cheapest defence is to stop asking the author to simulate a precedence table in their head and just print the tree back at them, fully parenthesised:

import ast

SYM = {ast.BitAnd: "&", ast.BitOr: "|", ast.Add: "+", ast.Sub: "-", ast.Mult: "*",
       ast.Div: "/", ast.Pow: "**", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">",
       ast.GtE: ">=", ast.Eq: "==", ast.NotEq: "!="}

def grouped(source: str) -> str:
    """Render an expression with every grouping made explicit."""
    def walk(node):
        if isinstance(node, ast.BoolOp):
            op = " and " if isinstance(node.op, ast.And) else " or "
            return "(" + op.join(walk(v) for v in node.values) + ")"
        if isinstance(node, ast.BinOp):
            return f"({walk(node.left)} {SYM[type(node.op)]} {walk(node.right)})"
        if isinstance(node, ast.Compare):
            out, left = "", walk(node.left)
            for op, right in zip(node.ops, node.comparators):
                out += f"({left} {SYM[type(op)]} {walk(right)}) and "
                left = walk(right)
            return "(" + out[:-5] + ")" if len(node.ops) > 1 else out[:-5]
        return ast.unparse(node)
    return walk(ast.parse(source, mode="eval").body)

Its output on the cases above:

a > 1 & b > 2               ->  ((a > (1 & b)) and ((1 & b) > 2))
(a > 1) & (b > 2)           ->  ((a > 1) & (b > 2))
a > 1 and b > 2             ->  ((a > 1) and (b > 2))
(rsi < 30) | rsi > 70       ->  (((rsi < 30) | rsi) > 70)
rsi < 30 or rsi > 70        ->  ((rsi < 30) or (rsi > 70))
close > ema50 and adx > 25  ->  ((close > ema50) and (adx > 25))

Nobody needs the precedence table to read line four and see that rsi is being OR'd with a boolean and then compared to 70.

Which is the general rule, and it is not specific to trading. If your expression language accepts operators whose precedence differs from the reader's intuition — and if you hand the job to a host-language parser, it will — then the parse tree is part of the contract, not an implementation detail, and the author is entitled to see it. A validator that says accepted answers the wrong question. The author's question is is this what I said, and the grouping is the whole of the answer.

We do not ship that echo today. The builder gets a palette derived from the engine's own registry so it cannot offer something the loader would reject (api/services/authoring_vocabulary.py), and a preview that draws the columns straight out of the run's own namespace so the chart cannot disagree with the engine (api/services/authoring_preview.py). Both show what the rule computed. Neither shows how it grouped. For a rule that silently never fires, the preview shows an empty column and an author who trusts their own line has no reason to suspect the parser. The grouped rendering is nine lines and belongs next to it.

KONIS is quantitative trading infrastructure. Backtest declarative strategies against years of exchange data, run what survives on a managed runtime, and read the market through analytics while it runs.

Build and backtest free — no card

Technical software and market analytics, not financial, investment or trading advice. Nothing here is a recommendation to buy or sell anything. Backtested results are historical and do not indicate future results. Trading carries substantial risk, including total loss of capital.