Parsing trading rules I
I will describe here how in CryptoQuant I architected parsing user-input trading rules into a machine runnable AST.
This parsing system forms the critical bridge between human-readable trading strategies and executable code that can efficiently process market data - ultimately allowing our users to automate sophisticated trading strategies without requiring deep programming expertise.
The challenge of parsing trading rules
One of the key features of CryptoQuant (a platform for automated cryptocurrency trading) is allowing users to define their own trading strategies using a precise yet flexible syntax.
These rules need to be both user-friendly to write and powerful enough to represent complex trading logic. The core challenge is transforming text-based rules from the frontend into executable code that can evaluate trading conditions against market data.
As I mentioned in my architecture article, f# is my primary backend language for CryptoQuant.
Actually the reason I chose f# was experimentation with trading rule parser, which went much better and smoother than I would have imagined in the beginning, so I just kept going. I finally chose to write entire backend in f# as a result.
The typescript to AST1 approach
For our trading rule language, I decided to use a subset of typescript and not re-invent the wheel. This approach has several advantages:
- Users familiar with javascript/typescript can easily write rules
- We leverage existing typescript tooling for syntax highlighting and basic validation
- The Angular frontend can pre-validate code before sending it to the backend
Here's an example of what a trading rule might look like for a user:
// Buy when RSI is oversold and short-term moving average crosses above long-term
const entry = (priceMinute().oscillator.rsi(14) < constant(30)) && (priceMinute().trend.sma(5) > priceMinute().trend.sma(20))
On the frontend, we use codemirror2 with typescript intellisense to provide a rich editing experience, more on that in the future article. Only valid typescript that passes frontend validation is sent to our f# backend for deeper semantic analysis and ast (abstract syntax tree) creation.
Modeling AST in F#
F# is exceptionally well-suited for parsing due to its discriminated unions3, which make modeling complex hierarchical structures both elegant and type-safe. Here is a simplified version of how we define our ast:
type Arithmetic =
| Add
| Subtract
| Multiply
| Divide
type TechnicalIndicator =
| Sma
| Rsi
| Ema
type Frequency =
| Day
| Minute
type Indicator = Price of Frequency
type Formula =
| TechnicalOp of TechnicalIndicator * int * Formula
| ArithmeticOp of Formula * Arithmetic * Formula
| Constant of float
| Indicator of Indicator
The beauty of this approach is that the Formula
type is recursive - it can contain other formulas within it.
This elegantly represents the nested nature of expression like
// moving average of moving average of moving average of price
priceMinute().sma(14).sma(14).sma(14)
Although artificially constructed and slightly stretched, would mean 3 consecutive moving averages applied to price series.
The logical expressions that make up our trading rules are similarly modeled:
type Comparison =
| LT
| GT
| EQ
| LTE
| GTE
type BooleanOp =
| And
| Or
type Logical =
| CompoundLogical of Logical * BooleanOp * Logical
| CompoundFormula of Formula * Comparison * Formula
The parsing pipeline using FParsec
For the actual parsing logic, I use fparsec4, a parser combinator5 library for f#. Parser combinators allow you to build complex parsers from smaller, more focused parsers - a perfect fit for f#'s functional composition approach.
Here's how we build recursive parsers to handle our nested expressions:
// Create a forward reference for our formula parser to handle recursion
let pFormula, pFormulaRef = createParserForwardedToRef<Formula, unit>()
// Parse a simple indicator
let priceDay = pLiteral "priceDay" >>. parens spaces >>% Indicator(Price(Day))
let priceMinute = pLiteral "priceMinute" >>. parens spaces >>% Indicator(Price(Minute))
// Parse a constant value
let pConstant = pLiteral "constant" >>. parens pFloat |>> Constant
// Parse a function with a single integer parameter
let pSma = pLiteral "sma" >>. parens pInt |>> fun period innerExpr -> TechnicalOp(Sma, period, innerExpr)
// Parse a binary operation between two expressions
let pArithmeticOp operatorName opType =
pLiteral operatorName
>>. parens pFormula
|>> fun right left -> ArithmeticOp(left, opType, right)
// Define our formula parser that can handle nested calls
let pChained =
pipe2
(choice [priceMinute; priceDay; pConstant])
(many (
pDot
>>. choice [
pSma
// Other technical indicators
pArithmeticOp "add" Add
pArithmeticOp "subtract" Subtract
// Other arithmetic operations
]
))
(List.fold (fun acc fn -> fn acc))
// Connect our forward reference to enable recursion
do pFormulaRef.Value <- pChained
The magic happens with createParserForwardedToRef
which lets us define parsers that can refer to themselves - essential for handling our recursive expression syntax. When parsing something like priceMinute().sma(14).add(priceDay().sma(30))
, the parser needs to handle nested expressions on both sides of the add
operation.
This recursive approach mirrors exactly how our AST is structured, making the parser a natural fit for our domain language. The parser combinators compose together just like our expressions do, creating an elegant solution for transforming text into our typed AST.
Railway oriented error handling
When parsing user input, things can go wrong in various ways. f# makes error handling elegant with the Result
type, enabling what's often called railway oriented programming6 - a technique where success and failure flow along separate tracks:
let validateUniverse (backtest: Backtest) =
let items = backtest.Universe.Value
if List.isEmpty items then
Error "Universe must contain at least one instrument"
elif List.length items > 20 then
Error "Universe cannot contain more than 20 instruments"
elif Set.count (Set.ofSeq items) <> List.length items then
Error "Universe cannot contain duplicate instruments"
else
Ok backtest // Pass the valid backtest along the success track
let parse (input: string) : Result<Backtest, ApiError> =
input
|> FParsec.CharParsers.run Backtest.parse
|> ParserResult.toResult
|> Result.bind validateUniverse
|> Result.bind validateStrategyLogic
|> Result.mapError ApiError.ParserError
This pipeline approach creates a clean separation between parsing and validation stages. Each function in the pipeline either passes its successful result to the next function or short-circuits with an error. The Result.bind
function is key here - it only applies the next function if the previous step succeeded.
The beauty of this pattern is that we can add new validation steps without changing existing code. Each validation function has a single responsibility and can be tested in isolation. This provides a clear way to distinguish between successful parses and failures, with detailed error information to guide the user when something goes wrong.
Stay tuned for the following article on the frontend architecture which will demonstrate how users experience these error suggestions in a user-friendly way.
The API bridge between frontend and backend
To connect our frontend with our parser, we expose a simple REST API:
[<HttpPost>]
member this.Parse([<FromBody>] content: Content) =
this.Parse(content.Content)
|> TaskResult.ofResult
|> fun x -> this.toIActionResult(logger, x)
This endpoint takes typescript code from the user, parses it into our AST, and returns either a success result or a detailed error information.
Here is a simplified chart of the entire pipeline:
Benefits of the F# approach
Using f# for parsing trading rules has provided several key benefits:
- Pattern matching: Makes complex tree traversal and transformation straightforward
- Discriminated unions: Perfect for modeling hierarchical and recursive structures like ASTs
- Immutability: Guarantees thread safety and simplifies reasoning about the code
- Composition: Parser combinators align naturally with f#'s functional approach
Conclusion
Parsing user-defined trading rules is a perfect showcase for f#'s strengths. The combination of powerful type system features, pattern matching, and immutability makes implementing a robust parser straightforward and maintainable.
In the next article, I'll dive into the frontend side of this implementation, showing how we use typescript, angular and codemirror to provide a seamless editing experience before sending the validated code to the backend.
The full source code for this implementation will be shared in a public repository once both articles are completed, allowing the astute reader to explore both the frontend and backend aspects of this solution.
Postscript
In the article I described my initial approach to strategy input parsing. The approach was to parse subset of typescript from the user. This has a significant threshold for the user sophistication. I finally settled on visual programming7 on the frontend, while the backend part of the parser did not change.
Footnotes
-
AST (Abstract Syntax Tree): A tree representation of the abstract syntactic structure of source code. In our case, it's the structured format that represents trading rules in a way the computer can efficiently process. ↩
-
CodeMirror: A versatile text editor for browsers, often used for code editing in web applications. ↩
-
Discriminated Union: Also known as a tagged union or sum type, is a data structure that can hold values of different types, but always "knows" which type it currently holds. A core feature of f# that makes it excellent for modeling complex domains. ↩
-
FParsec: A parser combinator library for f# that allows building complex parsers by combining smaller parsing functions. Read more about it here. ↩
-
Parser Combinator: A higher-order function that accepts parsers as input and returns a new parser as output, allowing the composition of complex parsers from simpler ones. ↩
-
Railway Oriented Programming: An approach to error handling in functional programming that visualizes the success and failure paths as parallel tracks on a railway, with functions acting as points that can switch between tracks. For more details you could check this article. ↩
-
Visual Programming: A programming paradigm where users create programs by manipulating elements graphically rather than by specifying them textually. For an example of this check quite popular scratch used in elementary education. ↩