Trading rule evaluation and signal generation service for the systematic trading platform.
The Decision Engine consumes technical indicator events from the Analytics Service, evaluates configurable trading rules, and produces:
- Individual Decisions (
trading.decisions) - BUY/SELL/WATCH signals per stock - Rankings (
trading.rankings) - Cross-stock comparison: "Which stock should I buy first?"
The engine implements your core strategy: "Buy dips in uptrending stocks"
| Rule | Condition | Signal |
|---|---|---|
| Buy Dip in Uptrend | RSI < 40 AND SMA_20 > SMA_50 | BUY |
| Strong Buy Signal | RSI < 35 AND SMA_20 > SMA_50 > SMA_200 | BUY (highest confidence) |
| Weekly Uptrend | SMA_20 > SMA_50 | Required for buy |
| Monthly Uptrend | SMA_50 > SMA_200 | Preferred, not required |
- RSI Oversold/Overbought
- MACD Bullish/Bearish Crossover
- Full Trend Alignment (Golden Cross)
- Trend Break Warning
When you have multiple BUY signals, the ranker answers: "Which should I buy first?"
Ranking weights:
- Dip Depth (30%): Lower RSI = better entry
- Trend Strength (30%): Full SMA alignment preferred
- Confidence (25%): More rules agreeing = higher confidence
- Volatility (15%): Moderate volatility preferred
- Describe your rule in plain English
- I translate it to a Python rule class
- Add to
config/rules.yaml
Example rule in rules/composite_rules.py:
class BuyDipInUptrendRule(Rule):
"""
YOUR PRIMARY RULE:
Natural Language: "Buy when RSI dips to 35-40 AND weekly uptrend is intact"
"""
@property
def description(self) -> str:
return "Buy when RSI < 40 AND weekly uptrend intact (SMA_20 > SMA_50)"Edit config/rules.yaml to:
- Enable/disable rules
- Adjust thresholds (RSI levels, etc.)
- Set rule weights for ranking
| Env var | Default | Purpose |
|---|---|---|
CLOCK_MODE |
real |
replay reads simulated time from Redis; anything else (including a typo) is real |
CLOCK_SIM_KEY |
sim:clock |
Redis key holding an ISO-8601 simulated time |
The engine reads "now" through decision_engine.clock rather than calling
datetime.utcnow() directly, so the e2e-replay harness can
drive it with simulated time. Every relative-duration gate depends on this:
signal debounce, ranking interval, context staleness, earnings staleness,
stale-signal and stale-state eviction, tier cache TTL, and the trade plan's
validity window.
Production behaviour is unchanged. CLOCK_MODE defaults to real, only
the exact string replay switches modes, and the real path never opens a
Redis connection for the clock. In replay mode initialize() fails fast if the
driver has not published a simulated time, rather than producing a whole run
quietly stamped with today's date.
Three things deliberately keep the wall clock, because simulated time would make them wrong rather than right:
EVALUATION_DURATION— a latency metric; measuring real work in simulated time records zero.- The trade planner's Redis circuit-breaker backoff — Redis is a real server during a replay, so its 15-second backoff must elapse in real seconds.
rules_cacheconfig caching — rules are static across a run, and a simulated TTL would re-read Redis on every simulated minute.
decision_engine.clock exposes three accessors that are exact drop-in
replacements preserving the tz-awareness each call site already had:
| Call site used | Replace with | Returns |
|---|---|---|
datetime.utcnow() |
clock.utcnow() |
naive UTC |
datetime.now(timezone.utc) |
clock.now() |
aware UTC |
time.time() |
clock.timestamp() |
float epoch |
This is not cosmetic. Signal timestamps, last_update, and entry_date are
naive throughout the service; returning an aware datetime from utcnow() would
raise TypeError: can't subtract offset-naive and offset-aware datetimes at
every comparison.
# Local development
cp .env.example .env
# Edit .env with your settings
python -m decision_engine.mainThe service entrypoint is the decision_engine.main module (also the Docker
image's CMD).
This repo ships a Dockerfile but not a docker-compose.yml — Compose is
defined at the platform level, where this service is orchestrated alongside
Kafka/Redpanda, Redis, and the other trading services. To run the container
standalone:
docker build -t decision-engine .
docker run --rm --env-file .env decision-engineWhen run as part of the platform stack, bring it up with the platform-level
Compose file (e.g. docker compose up decision-engine).
{
"event_type": "INDICATOR_UPDATE",
"data": {
"symbol": "AAPL",
"indicators": {
"RSI_14": 28.5,
"MACD": 0.12,
"SMA_20": 150.00,
"SMA_50": 148.00,
"SMA_200": 145.00
}
}
}{
"event_type": "DECISION_UPDATE",
"data": {
"symbol": "AAPL",
"signal": "BUY",
"confidence": 0.85,
"primary_reasoning": "BUY DIP: deep dip (RSI: 28.5) in strong uptrend",
"rules_triggered": [...]
}
}{
"event_type": "RANKING_UPDATE",
"data": {
"signal_type": "BUY",
"rankings": [
{"rank": 1, "symbol": "WPM", "score": 0.92},
{"rank": 2, "symbol": "AAPL", "score": 0.85}
]
}
}Rules are compatible with the backtesting service via the RuleBasedStrategy adapter:
from decision_engine.adapters.backtesting import RuleBasedStrategy
from decision_engine.rules.composite_rules import BuyDipInUptrendRule
strategy = RuleBasedStrategy(
rules=[BuyDipInUptrendRule()],
min_confidence=0.6
)A large portion of this project — implementation, tests, and documentation — was written in pair-programming sessions with Claude Code, Anthropic's agentic command-line tool.