[WITH CODE] Model: FURIA algorithm
Build rules that breathe with the market, not break under pressure
Table of contents:
Introduction.
Assessing limitations.
Embracing fuzzy logic as a strategic catalyst.
Theoretical framework of fuzzy rule induction.
Purity and certainty in fuzzy rule evaluation.
The FURIA algorithm.
Introduction
Let’s be real—markets are messy, volatile beasts. For years, algo strategies have leaned on these crisp, binary rules: If X crosses threshold Y, execute Z. But anyone who’s spent time on a trading floor knows markets don’t play by tidy rules. Take a classic momentum signal—say, a ‘buy’ trigger when price breaches a moving average. The second that threshold gets crossed, you’re supposed to act. But here’s the rub: markets don’t flip a switch. That ‘buy’ signal might fire while the broader context—liquidity, volatility, order flow—is still in this murky transition. It’s like slapping a ‘storm warning’ on a radar blip that’s still just a ripple.
So now you’re stuck with this quant dilemma: How do you teach machines to navigate ambiguity when their entire framework is built on yes/no logic? Standing there, staring at screens drowning in noise, it hits you—forcing binary decisions on markets is like trying to track a hurricane with a light switch. You’re either ‘on’ or ‘off,’ while the storm rages in gradients.
Assessing limitations
To build a robust trading system, we must confront several risks imposed by conventional rule-based systems:
Loss of nuance: Traditional classifiers that enforce hard decision boundaries provide no margin for error, often leading to frequent rebalancing and overtrading as market conditions oscillate around a fixed threshold.
Systematic biases: Ordered rule lists, common in methods like RIPPER, tend to privilege one class—often the default or majority class. In trading, this bias can result in a persistent overexposure to certain market conditions.
Inflexible adaptation: The inability to deal with uncovered instances—i.e., those market conditions that do not neatly satisfy any crisp rule—forces traders to rely on default actions that may be suboptimal.
Inadequate risk management: Abrupt transitions can lead to higher transaction costs, slippage, and adverse selection. All these factors compound to worsen the overall trading performance.
These risks are paralleled in any system that fails to model uncertainty. Markets do not respond in binary fashion; they rather exhibit a continuum of responses based on partially observable factors, leading to the conclusion that a more nuanced approach is needed.
If you're curious, don't miss this article in which I develop the RIDRA algorithm:
Embracing fuzzy logic as a strategic catalyst
The pivotal moment comes with the realization that fuzzy logic offers the perfect antidote to the rigidity of crisp rule-based models. Fuzzy logic introduces the concept of graded membership, in which data points can belong to a set to a certain degree rather than in an absolute manner.
Imagine a trader no longer making decisions solely on whether a price is above or below a specific value, but rather, assessing the degree to which the price aligns with a bullish or bearish condition. This is analogous to a spectrum rather than a binary state. By replacing sharp decision boundaries with soft fuzzy intervals, we can simulate the gradual transitions common in the market data.
The Fuzzy Unordered Rule Induction Algorithm (FURIA) is built on this very philosophy. It modifies the state-of-the-art RIPPER algorithm by:
Learning fuzzy rules: FURIA replaces conventional crisp rules with fuzzy rules that have gradual membership functions.
Unordered rule sets: Instead of imposing an order that may bias the model, FURIA learns rules for each class independently in a one-vs.-rest manner.
Rule stretching: To manage uncovered examples—market states that lie in-between preset conditions—FURIA utilizes a rule stretching mechanism that generalizes rules in a local, data-driven way.
This breakthrough is the catalyst for a new class of algorithmic trading systems—one that can adapt to the fluid, ambiguous nature of financial markets.
If you want to check it out in more depth, check out this paper:
Theoretical framework of fuzzy rule induction
Before diving into implementation details, we must first establish a firm mathematical foundation. Fuzzy logic extends classical set theory by allowing partial membership. Instead of an element belonging fully to a set or not at all, fuzzy sets enable degrees of membership. A common representation is the trapezoidal membership function. Consider a financial indicator x—for instance, a normalized momentum score. A fuzzy set IF representing “bullish momentum” may be defined by a trapezoidal function with four parameters:
The corresponding membership function is given by:
Here, ϕc,L and ϕc,U define the core—complete membership region—and ϕs,L and ϕc,U define the support—the transition zones. In trading, this gradual transition is analogous to assessing the degree of bullishness of a market condition rather than a binary yes/no.
Furthermore, for a fuzzy rule composed of k conditions—for different market indicators—the overall degree of coverage of an instance x—a vector of market data—is given by the product of the individual memberships:
This multiplicative aggregation reflects the joint likelihood that the market condition x is consistent with a given rule. Higher values indicate a stronger match.
Purity and certainty in fuzzy rule evaluation
To fine-tune fuzzy rules, FURIA uses the concept of purity. For a given rule, let:
p be the weighted sum of positive—target—instances,
n be the weighted sum of negative instances, with weights given by the fuzzy membership of each instance. The purity is then expressed as:
This metric is later used to choose the best candidate support boundaries during fuzzification.
The certainty factor for a rule—a value that adjusts the contribution of the rule in classification—is computed using a modified Laplace estimate:
where p and n are defined as before. This factor weighs each rule’s impact and is particularly crucial when multiple fuzzy rules are aggregated for decision making.
The FURIA algorithm
The original FURIA algorithm re-engineers the classical RIPPER algorithm with three significant modifications:
Fuzzy transformation: Traditional crisp rules—with hard decision boundaries—are converted into fuzzy rules by replacing strict intervals with fuzzy intervals—as described above. This makes rules softer and more adaptable to gradual changes in market data.
Unordered rule sets: Instead of creating an ordered rule list—which might bias the system toward a default class—FURIA learns an unordered set of rules using a one-vs.-rest approach. In algorithmic trading, this equal treatment ensures that all market states are evaluated fairly, without undue bias toward regular conditions.
Rule stretching: To handle instances that are not covered by any rule, FURIA employs a local strategy called rule stretching. In this process, a rule is generalized—by dropping one or more conditions—until it covers the query instance. Unlike a default rule, rule stretching leverages the local structure of the market data to adjust decisions dynamically.
Together, these modifications yield a robust classification system that can effectively map fuzzy market indicators into actionable trading signals. So the pseudo-code looks like this:
Now that we have a clear understanding, it’s time to code it!
The following function computes the trapezoidal membership, which is central to our fuzzy rule evaluation. In algorithmic trading, this may be used to assess the degree to which an indicator—e.g., RSI or moving average deviation, although I prefer to use another type of data for this type of algos—belongs to a bullish or bearish set.
import numpy as np
import matplotlib.pyplot as plt
def trapezoidal_membership(x, a, b, c, d):
"""
Compute the trapezoidal membership function for value(s) x.
a: left support
b: left core
c: right core
d: right support.
"""
x = np.array(x)
mu = np.zeros_like(x, dtype=float)
# Rising edge
rising = (x > a) & (x < b)
if (b - a) != 0:
mu[rising] = (x[rising] - a) / (b - a)
else:
mu[rising] = 1.0
# Core region
core = (x >= b) & (x <= c)
mu[core] = 1.0
# Falling edge
falling = (x > c) & (x < d)
if (d - c) != 0:
mu[falling] = (d - x[falling]) / (d - c)
else:
mu[falling] = 1.0
return mu
Imagine plotting μ(x) on the y-axis against x on the x-axis. With parameters a=2, b=4, c=6, and d=8, the function rises from 0 to 1 between 2 and 4, remains at 1 until 6, and then falls off to 0 at 8. In trading, such a smooth curve allows for measuring the degree of bullishness as market indicators vary gradually. Like this:
On the other hand, we define a FuzzyCondition
class that encapsulates conditions such as indicator ≤ threshold or indicator > threshold. Each condition has a core—where the rule holds completely—and a support—representing transition.
class FuzzyCondition:
"""
Represents a fuzzy condition derived from a crisp rule.
For "feature <= threshold", the crisp threshold is the core bound, and support is set with an offset delta.
"""
def __init__(self, feature_idx, op, threshold, delta=1.0):
self.feature_idx = feature_idx
self.op = op # can be "<=" or ">"
self.threshold = threshold
if op == '<=':
self.phi_c = threshold # Core: all values ≤ threshold are fully in the set.
self.phi_s = threshold + delta # Support: values above threshold until this point have decreasing membership.
elif op == '>':
self.phi_c = threshold # Core: all values > threshold.
self.phi_s = threshold - delta # Support: values below threshold until this point.
else:
raise ValueError("Operator must be '<=' or '>'")
def membership(self, x_value):
"""
Compute the fuzzy membership of x_value for this condition.
"""
if self.op == '<=':
if x_value <= self.phi_c:
return 1.0
elif x_value >= self.phi_s:
return 0.0
else:
return (self.phi_s - x_value) / (self.phi_s - self.phi_c)
elif self.op == '>':
if x_value >= self.phi_c:
return 1.0
elif x_value <= self.phi_s:
return 0.0
else:
return (x_value - self.phi_s) / (self.phi_c - self.phi_s)
def fuzzify(self, X, y, pos_label):
"""
Optimize the support boundary using training examples relevant to the condition.
"""
feature_values = X[:, self.feature_idx]
if self.op == '<=':
candidates = feature_values[feature_values > self.threshold]
if len(candidates) > 0:
self.phi_s = np.min(candidates)
elif self.op == '>':
candidates = feature_values[feature_values < self.threshold]
if len(candidates) > 0:
self.phi_s = np.max(candidates)
A fuzzy rule is then built as a conjunction of multiple fuzzy conditions. In financial terms, a rule might combine several market indicators—e.g., moving average, momentum index or any other—into a composite trading signal.
class FuzzyRuleFull:
"""
Represents a fuzzy rule with multiple fuzzy conditions.
Each rule predicts a target class (e.g., buy, sell, hold) and computes an overall membership.
"""
def __init__(self, conditions, target_class):
self.conditions = conditions # List of FuzzyCondition objects.
self.target_class = target_class
self.certainty_factor = 1.0 # Will be computed using training examples.
def rule_membership(self, x):
"""
Computes the overall rule membership for an instance x as the product of individual condition memberships.
"""
mu = 1.0
for cond in self.conditions:
mu *= cond.membership(x[cond.feature_idx])
return mu
def fuzzify_rule(self, X, y):
"""
Iteratively optimizes the support boundary of each condition based on relevant training data.
"""
for i, cond in enumerate(self.conditions):
mask = np.ones(len(X), dtype=bool)
for j, other in enumerate(self.conditions):
if i == j:
continue
mask &= (np.array([other.membership(x_val) for x_val in X[:, other.feature_idx]]) > 0.01)
if np.sum(mask) > 0:
cond.fuzzify(X[mask], y[mask], self.target_class)
def compute_certainty(self, X, y):
"""
Computes the certainty factor (CF) for the rule via a modified Laplace estimate.
Only examples with rule membership above 0.1 are considered.
"""
memberships = np.array([self.rule_membership(x) for x in X])
covered = memberships > 0.1
if np.sum(covered) == 0:
self.certainty_factor = 0.0
else:
pos = np.sum(y[covered] == self.target_class)
neg = np.sum(y[covered] != self.target_class)
self.certainty_factor = (pos + 1.0) / (pos + neg + 2.0)
return self.certainty_factor
def stretch_rule(self, x):
"""
Implements a minimal generalization (rule stretching) for an instance x that is not covered by the rule.
Conditions are progressively dropped until the instance receives a non-zero membership.
"""
for drop in range(len(self.conditions)):
new_conditions = self.conditions[:len(self.conditions)-drop]
mu = 1.0
for cond in new_conditions:
mu *= cond.membership(x[cond.feature_idx])
if mu > 0.0:
return new_conditions, mu
return [], 0.0 # fallback
def __str__(self):
cond_str = " AND ".join([f"f{cond.feature_idx} {cond.op} {cond.threshold:.2f}"
for cond in self.conditions])
return f"If {cond_str} then class = {self.target_class} (CF={self.certainty_factor:.2f})"
FURIA builds upon RIPPER by extracting rules from data. In our implementation, we use a full decision tree to extract multi-condition crisp rules, which we then convert into fuzzy rules. In algorithmic trading, this is analogous to first identifying distinct market regimes with a decision tree and then smoothing the boundaries between regimes for better adaptability.
from sklearn.tree import DecisionTreeClassifier
def extract_rules_from_tree(dt, feature_names=None):
"""
Recursively extracts crisp rules from a decision tree.
Returns a list of tuples: (conditions, target_class) where each condition is a tuple (feature, operator, threshold).
"""
tree = dt.tree_
rules = []
def recurse(node, conditions):
if tree.feature[node] != -2: # Internal node
feature = tree.feature[node]
threshold = tree.threshold[node]
left_conditions = conditions.copy()
left_conditions.append((feature, "<=", threshold))
recurse(tree.children_left[node], left_conditions)
right_conditions = conditions.copy()
right_conditions.append((feature, ">", threshold))
recurse(tree.children_right[node], right_conditions)
else:
# Leaf node: assign the class with the maximum votes.
values = tree.value[node][0]
target_class = np.argmax(values)
rules.append((conditions, target_class))
recurse(0, [])
return rules
In the trading context, each instance corresponds to a market snapshot—comprising features such as technical indicators, price volatility, and volume. Each fuzzy rule represents an if-then statement that maps certain market conditions—with partial memberships—to trading actions—e.g., buy, sell, hold. The unordered rule set obtained via one-vs.-rest decomposition ensures that each trading regime is modeled symmetrically, without a bias toward a default state.
Moreover, the rule stretching mechanism guarantees that even in cases where a snapshot does not meet any pre-defined market condition, the system can generalize locally, ensuring that no valuable market data go unutilized. This local generalization mimics how an expert trader might relax a condition during periods of extreme market volatility.
Let’s check the output!
These lines show the fuzzy (or partially fuzzified) rules that the algorithm extracted from your data. Each rule is effectively:
A set of numeric conditions (like
f1 <= 3.5
,f2 <= 4.83
, etc.).A resulting class (here,
0
,1
, or2
).A certainty factor (CF), which numerically reflects how reliably that rule predicts its class, based on the training set.
Notably, some rules have a very low CF (for example, CF=0.00), suggesting those rules barely cover—or accurately predict—any data points; others have higher CF values above 0.9, indicating those rules more reliably identify their respective classes.
Besides if we plot of the membership function for one of the extracted conditions—e.g., “f1 <= 1.97”—we can see that for feature f1 ≤ 1.97, the membership in that condition is 1—the rule is fully active—and for f1
> 1.97, membership is 0—the rule is inactive. Hence, it’s more of a crisp boundary than a softly sloping trapezoidal membership.
Alright, team! We’ll dive deeper into ensemble power tomorrow—until then, polish your features, trust your data, and may your alpha outrun the noise! Stay clever, stay quanty!💡
PS: Which topics are you most interested in exploring further?
👍 Who do you know who is interested in quantitative finance and investing?
Appendix
Full script:
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_iris
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
###############################################################################
# Helper functions and classes
###############################################################################
def trapezoidal_membership(x, a, b, c, d):
"""
Compute the trapezoidal membership for value(s) x.
a: left support; b: left core; c: right core; d: right support.
"""
x = np.array(x)
mu = np.zeros_like(x, dtype=float)
rising = (x > a) & (x < b)
if (b - a) != 0:
mu[rising] = (x[rising] - a) / (b - a)
else:
mu[rising] = 1.0
core = (x >= b) & (x <= c)
mu[core] = 1.0
falling = (x > c) & (x < d)
if (d - c) != 0:
mu[falling] = (d - x[falling]) / (d - c)
else:
mu[falling] = 1.0
return mu
###############################################################################
# Fuzzy condition and fuzzy rule classes
###############################################################################
class FuzzyCondition:
"""
Represents a fuzzy condition derived from a crisp rule.
For a condition "feature <= threshold": the crisp threshold is the core bound,
and the support is set to threshold + delta.
For "feature > threshold", the crisp threshold is the lower core bound.
"""
def __init__(self, feature_idx, op, threshold, delta=1.0):
self.feature_idx = feature_idx
self.op = op # "<=" or ">"
self.threshold = threshold
if op == '<=':
self.phi_c = threshold
self.phi_s = threshold + delta
elif op == '>':
self.phi_c = threshold
self.phi_s = threshold - delta
else:
raise ValueError("Operator must be '<=' or '>'")
def membership(self, x_value):
if self.op == '<=':
if x_value <= self.phi_c:
return 1.0
elif x_value >= self.phi_s:
return 0.0
else:
return (self.phi_s - x_value) / (self.phi_s - self.phi_c)
elif self.op == '>':
if x_value >= self.phi_c:
return 1.0
elif x_value <= self.phi_s:
return 0.0
else:
return (x_value - self.phi_s) / (self.phi_c - self.phi_s)
def fuzzify(self, X, y, pos_label):
feature_values = X[:, self.feature_idx]
if self.op == '<=':
candidates = feature_values[feature_values > self.threshold]
if len(candidates) > 0:
self.phi_s = np.min(candidates)
elif self.op == '>':
candidates = feature_values[feature_values < self.threshold]
if len(candidates) > 0:
self.phi_s = np.max(candidates)
class FuzzyRuleFull:
"""
Represents a fuzzy rule with a conjunction of fuzzy conditions.
Each rule predicts a target class and is assigned a certainty factor.
"""
def __init__(self, conditions, target_class):
self.conditions = conditions
self.target_class = target_class
self.certainty_factor = 1.0
def rule_membership(self, x):
mu = 1.0
for cond in self.conditions:
mu *= cond.membership(x[cond.feature_idx])
return mu
def fuzzify_rule(self, X, y):
for i, cond in enumerate(self.conditions):
mask = np.ones(len(X), dtype=bool)
for j, other in enumerate(self.conditions):
if i == j:
continue
mask &= (np.array([other.membership(x_val) for x_val in X[:, other.feature_idx]]) > 0.01)
if np.sum(mask) > 0:
cond.fuzzify(X[mask], y[mask], self.target_class)
def compute_certainty(self, X, y):
memberships = np.array([self.rule_membership(x) for x in X])
covered = memberships > 0.1
if np.sum(covered) == 0:
self.certainty_factor = 0.0
else:
pos = np.sum(y[covered] == self.target_class)
neg = np.sum(y[covered] != self.target_class)
self.certainty_factor = (pos + 1.0) / (pos + neg + 2.0)
return self.certainty_factor
def stretch_rule(self, x):
for drop in range(len(self.conditions)):
new_conditions = self.conditions[:len(self.conditions)-drop]
mu = 1.0
for cond in new_conditions:
mu *= cond.membership(x[cond.feature_idx])
if mu > 0.0:
return new_conditions, mu
return [], 0.0
def __str__(self):
cond_str = " AND ".join([f"f{cond.feature_idx} {cond.op} {cond.threshold:.2f}"
for cond in self.conditions])
return f"If {cond_str} then class = {self.target_class} (CF={self.certainty_factor:.2f})"
###############################################################################
# Rule extraction from a full decision tree
###############################################################################
def extract_rules_from_tree(dt, feature_names=None):
tree = dt.tree_
rules = []
def recurse(node, conditions):
if tree.feature[node] != -2:
feature = tree.feature[node]
threshold = tree.threshold[node]
left_conditions = conditions.copy()
left_conditions.append((feature, "<=", threshold))
recurse(tree.children_left[node], left_conditions)
right_conditions = conditions.copy()
right_conditions.append((feature, ">", threshold))
recurse(tree.children_right[node], right_conditions)
else:
values = tree.value[node][0]
target_class = np.argmax(values)
rules.append((conditions, target_class))
recurse(0, [])
return rules
###############################################################################
# FURIA Algorithm
###############################################################################
class FURIAFull:
"""
Full (improved) FURIA algorithm.
- Extracts crisp rules from a decision tree.
- Converts crisp rules into fuzzy rules using FuzzyCondition.
- Optimizes (fuzzifies) rules using training data.
- Computes certainty factors and applies rule stretching.
"""
def __init__(self):
self.rules = []
self.classes_ = None
def fit(self, X, y, feature_names=None):
dt = DecisionTreeClassifier(random_state=42, min_samples_leaf=5)
dt.fit(X, y)
self.classes_ = np.unique(y)
raw_rules = extract_rules_from_tree(dt, feature_names)
for conds, tclass in raw_rules:
fuzzy_conds = []
for (f, op, thr) in conds:
fuzzy_conds.append(FuzzyCondition(f, op, thr, delta=1.0))
rule = FuzzyRuleFull(fuzzy_conds, tclass)
rule.fuzzify_rule(X, y)
rule.compute_certainty(X, y)
self.rules.append(rule)
print("Extracted Full FURIA Rules:")
for r in self.rules:
print(r)
def predict_instance(self, x):
support = {c: 0.0 for c in self.classes_}
for rule in self.rules:
mu = rule.rule_membership(x)
if mu < 0.1:
new_conditions, mu_stretched = rule.stretch_rule(x)
mu = mu_stretched
support[rule.target_class] += mu * rule.certainty_factor
if all(s < 0.1 for s in support.values()):
return self.classes_[0]
return max(support, key=support.get)
def predict(self, X):
predictions = []
for x in X:
predictions.append(self.predict_instance(x))
return np.array(predictions)
###############################################################################
# Main
###############################################################################
if __name__ == '__main__':
# For demonstration, we use the Iris dataset as a stand-in for market data.
# In practice, replace this with historical trading indicators.
from sklearn.model_selection import train_test_split
np.random.seed(42)
n_samples = 150
n_features = 4
n_classes = 3
X = np.zeros((n_samples, n_features))
y = np.zeros(n_samples, dtype=int)
# Create synthetic features with class-dependent distributions
for i in range(n_classes):
start = i * 50
end = start + 50
# Different means and stds per class to simulate market indicators
means = [3*i, 2*i+1, i+4, 5-0.5*i] # Class-specific patterns
stds = [1.2, 0.8, 1.0, 0.9]
for j in range(n_features):
X[start:end, j] = np.random.normal(means[j], stds[j], 50)
y[start:end] = i
feature_names = [f"Indicator_{i}" for i in range(n_features)]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42)
# Train the full FURIA algorithm.
furia_full = FURIAFull()
furia_full.fit(X_train, y_train, feature_names)
preds_full = furia_full.predict(X_test)
accuracy_full = np.mean(preds_full == y_test)
print(f"\nTest Accuracy (Full FURIA): {accuracy_full*100:.2f}%")
for i in range(5):
print(f"Market snapshot: {X_test[i]}, True action: {y_test[i]}, Predicted action: {preds_full[i]}")
# Visualize fuzzy membership for the first condition of the first rule.
first_rule = furia_full.rules[0]
if len(first_rule.conditions) > 0:
cond = first_rule.conditions[0]
x_vals = np.linspace(np.min(X_train[:, cond.feature_idx]) - 1,
np.max(X_train[:, cond.feature_idx]) + 1, 400)
y_vals = [cond.membership(x) for x in x_vals]
plt.figure(figsize=(8, 4))
plt.plot(x_vals, y_vals, label=f'Fuzzy MF for feature {cond.feature_idx} {cond.op} {cond.threshold:.2f}')
plt.axvline(cond.threshold, color='r', linestyle='--', label='Crisp threshold')
plt.title('Fuzzy membership for an indicator condition')
plt.xlabel('Indicator Value')
plt.ylabel('Membership Degree')
plt.legend()
plt.grid(True)
plt.show()