EngineeringJune 22, 20268 min read

Build in Public: The POI State Machine — Why 7 States Beat a Boolean

The POI tracker started with one boolean: is_active. At 200 zones across multiple timeframes, it imploded. Here's how I replaced it with a 7-state machine where every transition has an explicit trigger — and where BLOCKED is a first-class concept, not a collision bug.

state machinePOIarchitecturePythonbuild-in-publicdeterminismRedis

Before LiquidMind existed, there was a Python script that checked every 5 minutes whether price was "near" something I'd drawn on a chart. A bool: is_active. One file. It worked fine.

It didn't scale.

The Problem With a Boolean

When a POI has two states — active or not — you lose the information about what happened. Is a POI "not active" because:

  • Price never got close? (still monitoring from a distance)
  • Price entered the zone but there's no structural confirmation yet? (waiting for ChoCH)
  • The entry model fired but requires my approval? (Approval mode)
  • An order is live on the exchange? (in a position)
  • Another POI is blocking entry to prevent opposing setups? (conflict state)
  • HTF structure broke through the zone? (permanently dead)

A boolean doesn't distinguish any of these. You end up querying debug logs to reconstruct what happened — and logs are not a state machine.

The 7-State Machine

Here's what runs today in infrastructure/db/postgre_client.py:

class POIState(Enum):
    ACTIVE           = "active"           # Detected on HTF, monitoring price proximity
    PENDING          = "pending"          # Price entered zone — LTF radar active
    READY            = "ready"            # LTF ChoCH + FVG confirmed, order params set
    WAITING_APPROVAL = "waiting_approval" # Human-in-the-Loop: waiting for user approval
    TRIGGERED        = "triggered"        # Order placed on exchange, sentinel monitoring
    BLOCKED          = "blocked"          # Conflicting active POI prevents entry
    INVALIDATED      = "invalidated"      # Terminal — zone destroyed, no re-evaluation

And the transitions between them:

ACTIVE ────────────── price enters zone ──────────────────► PENDING
PENDING ──────────── ChoCH + FVG confirmed ───────────────► READY
PENDING ──────────── HTF candle closes through zone ──────► INVALIDATED
READY ────────────── Autonomous mode: grade ≥ C ──────────► TRIGGERED
READY ────────────── Approval mode: waiting for OK ───────► WAITING_APPROVAL
WAITING_APPROVAL ─── user approves ──────────────────────► TRIGGERED
WAITING_APPROVAL ─── user rejects / zone breaks ─────────► INVALIDATED
TRIGGERED ───────────counter ChoCH / position closed ─────► INVALIDATED
any live state ──────conflicting POI detected ────────────► BLOCKED
BLOCKED ─────────── blocking POI resolves ───────────────► TRIGGERED

No transition is silent. Every state change writes status and status_updated_at into the Redis JSON. Key transitions stamp additional fields: entering PENDING writes pending_activated_at, entering TRIGGERED records order_id, entering BLOCKED writes blocked_at and blocked_reason.

What Each State Actually Does

ACTIVE — The HTF scanner registered a POI: an EFVG (Extreme Fair Value Gap), EQL (Equal Lows), BPR (Balanced Price Range), Breaker Block, or similar structure. No LTF analysis is running. The engine continuously tracks price-to-zone distance and fires the activation check when price gets within range.

PENDING — Price entered the activation threshold of the zone. The engine drops down to LTF surveillance: for a 4H POI it watches 15m candles, for a 1H POI it watches 5m. It hunts for structural confirmation: a Change of Character (ChoCH) that sweeps liquidity from the lower swing, followed by a displacement candle that creates an FVG targeting the limit order entry.

READY — Full confirmation: ChoCH printed, displacement confirmed, FVG identified as the precise limit order target. The scoring engine calculates a grade (A–F) from HTF alignment, LTF quality, and Market Cipher B confluence. If the grade clears the minimum threshold (C / score ≥ 50), the setup routes to execution.

WAITING_APPROVAL — Approval mode only. Setup is confirmed and graded, but the order won't fire until you approve it in the terminal. The POI waits here until you tap Approve or Reject — or until an invalidation condition fires independently.

TRIGGERED — Limit order is natively placed on the exchange (Bybit or Alpaca). SL is pinned at the swing origin, TP at the nearest liquidity target. The AI Sentinel keeps monitoring: if a counter-ChoCH prints on the entry timeframe after the order is live, the sentinel can invalidate and cancel the order.

BLOCKED — A conflicting POI of opposite direction is already in a live state. Rather than firing two opposing entries simultaneously, the system blocks the newer POI. When the blocking POI reaches TRIGGERED or INVALIDATED, blocked POIs of the same direction can be promoted directly to TRIGGERED if entry conditions still hold.

INVALIDATED — Terminal. Causes: (1) HTF candle body closed through the entire zone — structure broken at origin, (2) counter-ChoCH confirmed after TRIGGERED — setup reversed, (3) opposing liquidity swept before fill — invalidation guardrail, (4) user rejected in Approval mode. Once INVALIDATED, the POI is removed from the active queue. No second chances.

Interactive Simulator

You can run the full state machine step-by-step below. Three scenarios: a clean A-grade trade, a counter-ChoCH invalidation after order placement, and a TTL / zone breach. Each step shows the actual log lines the system would produce.


[interactive simulator]


Derive-Don't-Store — The Exception

I apply derive-don't-store to most computed fields in LiquidMind: distance to POI, trend alignment, session classification. If you can compute it from source data at query time, don't store it — it drifts.

POI state is the exception.

State is a historical fact, not a derivable value. You can't reconstruct "what state was this POI in at 11:41 UTC" from raw price data without replaying the entire tick stream. And even then, ambiguity creeps in — was a -0.3% wick an "entry" or a "touch"? The system that made the decision must record it at decision time.

Three concrete reasons I store it:

  1. Auditability — When a position opens unexpectedly, I can tell exactly when the POI moved ACTIVE → PENDING → READY → TRIGGERED, at what timestamps, and what structural event triggered each step.
  2. Distributed correctness — Multiple workers process the same symbols in parallel. Without stored state and atomic Redis updates, two workers can both attempt to enter the same POI. The atomic SET prevents race conditions.
  3. BLOCKED integrity — Blocked POIs must stay blocked even if entry conditions temporarily re-appear. Without explicit BLOCKED state persisted to Redis, the engine would fire entries that conflict with a live position.

Storage: Redis, Not PostgreSQL

POIs live in Redis, not a SQL table. Each POI is a JSON blob at a key like:

poi:BTCUSDT:240:EFVG:1748822400

An example PENDING state blob:

{
  "status": "pending",
  "status_updated_at": "2026-06-15T09:43:11.221Z",
  "pending_activated_at": "2026-06-15T09:43:11.221Z",
  "poi_type": "EFVG",
  "interval": "240",
  "side": 1,
  "ob_top": 67450.0,
  "ob_bottom": 66980.0,
  "strength": 84.2
}

And after triggering:

{
  "status": "triggered",
  "status_updated_at": "2026-06-15T10:02:44.009Z",
  "pending_activated_at": "2026-06-15T09:43:11.221Z",
  "order_id": "LM-0041-BUY"
}

Why Redis and not Postgres for POI state? State changes happen on every candle close for every tracked pair. At peak load that's hundreds of state updates per minute. Redis handles this without connection overhead, and the TTL mechanism automatically expires zones that would otherwise accumulate indefinitely.

The trade-off: no native event history. status_updated_at tells me when the last transition happened, but not the full journey. For per-position reasoning trails, the agent_decisions table in Postgres captures the LLM output behind each scoring and state-changing decision.

Agent Boundaries

The hardest architectural question: should the AI agent be allowed to directly mutate POI state?

No. And here's why the boundary matters.

The agent observes LTF structure, generates a reasoning chain about ChoCH quality and FVG alignment, and produces a structured output: entry confirmation or rejection, with a grade and reason. That output flows through the state update function before anything changes in Redis. If the agent contradicts the current state or produces an invalid grade, the update fails and the reasoning is logged without executing.

This separation matters operationally: I can rewrite agent prompts, swap the reasoning model, change scoring thresholds — without touching state correctness. The state machine is the contract. The agent is a client of that contract. Agent prompt experiments never corrupt live POI data.

What This Gave Me

Debuggability — When a position opens and I don't know why, the POI JSON tells me exactly when it moved through each state and what the status_updated_at timestamps were. "Why did this fire?" has a deterministic answer.

Testability — Unit tests for state filtering are pure lookups. I test that status == "pending" POIs receive LTF scans, that status == "triggered" POIs skip re-entry checks, that status == "blocked" POIs wait in queue. No mock market data needed.

BLOCKED as a first-class concept — Before explicit BLOCKED state, conflicting setups would sometimes double-fire or race. Now when two POIs would oppose each other, one gets BLOCKED with a reason field. When the conflict resolves, the promotion logic is clean and auditable.

Observability — I can chart INVALIDATED rate over time by cause. A spike in HTF_CANDLE_CLOSE_THROUGH means the market is breaking more structures than usual. A spike in COUNTER_CHOCH after TRIGGERED means something is wrong with LTF confirmation quality. The states turn invisible errors into visible metrics.

What's Next

The state machine is the deterministic spine. The next post will cover the entry scoring engine: how each POI in READY state gets a grade from A to F, what the inputs are (HTF alignment, LTF quality, MCB confluence, session), and why C is the minimum acceptable threshold rather than only A/B setups.

Select Simulation Scenario
POI State Machine — Live Visualizer
ACTIVE
PENDING
READY
TRIGGERED
INVALIDATED

Hover a node to inspect its state  ·  Select a scenario above to run the simulation

liquidmind@agent-log:~$
LIVE

> Select a scenario above to start the simulation...

ShareX / TwitterThreads
sc4mp

sc4mp

Founder of LiquidMind. Trading ICT/SMC concepts with AI-assisted tooling. Writing about what I build, what I break, and the trades that teach me the most.

@sc4mp

Stay in the loop

New posts on transparency, engineering, and the LiquidMind thesis — no noise.

Discussion

Sign in to join the discussion.