Engineering22 min read

Build in Public: Every session has a high and a low — and both get hunted

PDH/PDL gave the engine two liquidity magnets a day. But institutional desks don't just hunt yesterday's high — they hunt Asia's low before London commits, and London's high before New York reverses it. Here's how six new POI types, a shared invalidation core, and a full SMC/ICT context block got wired into the pipeline — and the naming mistake I made twice before shipping it. Update: shipping the radar UI surfaced a timezone bug and a default-allow config bug, and finished the move to full DST-aware sessions across the whole feature — level sizing and sweep-timing alike.

By

POISMC/ICTliquidityarchitecturebuild-in-publicPythonRedisagents

In brief

  • Six new POI types — one high/low pair per session (Asia, London, New York) — give the engine intraday liquidity magnets on top of the existing PDH/PDL, using the same entry-model confirmation pipeline.
  • A generic invalidate_single_level_poi() core now backs PDH/PDL, PWH/PWL, and the new session levels — one implementation instead of three near-identical copies.
  • The entry agent doesn't just see a price level: it gets sweep depth, reclaim status, both-sides-taken state, HTF liquidity confluence, and a deterministic Judas Sweep flag — all computed in code, never inferred by the LLM.
  • The POI type naming went through two rounds of collisions before shipping — SSL collided with Sell-Side Liquidity, then SL collided with Stop Loss — solved by making the session itself part of the type instead of trying to disambiguate in prose.
  • A session sweep is also a threat to any open position running into the level — the sweep now wakes the position-management agents with the same deterministic context block (sweep freshness, Judas flag, both-sides-taken), leaning PROTECT, never auto-exit.
  • Update: shipping the radar UI (session selector + boundary lines) surfaced two real bugs — a timezone parsing bug that shifted the drawn lines by the viewer's UTC offset, and a default-allow bug in the per-user POI config that would've made session liquidity live for every account the moment the worker created a level. Both fixed while finishing the move from narrow ICT killzones to full DST-aware trading sessions across the whole feature, including the sweep-timing side (sweep_session, Judas flag, 'current session' display) that the first pass hadn't reached yet — with the London/NY overlap explicitly attributed to New York.

The engine has had PDH/PDL — previous day high and low — for a while. Two liquidity magnets, refreshed once a day, rotated through the same pipeline as every other POI: price approaches, the LTF radar wakes up, it hunts for a ChoCH and a displacement, and only then does an entry get considered.

It's a good mechanism. It's also incomplete, because institutional desks don't only hunt yesterday's high. They hunt this morning's Asian range before London commits to a direction. They hunt London's high before New York reverses it. The session clock — not just the daily clock — produces liquidity pools that get swept on a schedule far tighter than once a day.

PDH/PDL gives you two chances a day. Session liquidity gives you up to six.

Update note: the sections below describe the original design, which sized each session's high/low from the narrow ICT killzone window (e.g. London 06:00–09:00 UTC). After shipping the radar UI this moved to the full DST-aware trading session instead (London ~07:00–16:00 UTC) — see Update: the radar UI shipped, and the window moved to full sessions near the end for what changed and why. The SMC/ICT reasoning, the six POI types, and the invalidation core below are all still accurate; the window boundaries and the "current session" example output changed — see the update for the current field names.

Why the session clock matters here specifically

I'd already wired the session clock into position management — five boundary events that make the Sentinel re-review open positions when London opens or New York closes. That work gave the platform DST-aware, timezone-correct session boundaries as a shared module.

This is a different use of the same clock. Instead of "review positions when a session changes," the question is "did this session's range just get raided, and is that raid the setup?"

The SMC/ICT reasoning is specific:

  • Asia's range (23:00–02:00 UTC) is typically tight, low-volume, low-conviction. Its high and low are exactly the kind of resting liquidity that gets swept once real volume shows up.
  • London open (06:00–09:00 UTC) is where that sweep usually happens — the so-called Judas Swing. Price often raids the Asian extreme, triggers retail stops sitting there, then reverses into the actual daily direction.
  • New York (12:00–15:00 UTC) is the London-NY overlap, peak liquidity, where London's move either gets confirmed or gets swept in turn.

Same mechanic as PDH/PDL — magnet, sweep, confirmation, entry — just running on a tighter, more mechanically-timed clock. And because the entry model still requires a confirmed LTF ChoCH and displacement before anything fires, a session level is exactly as strict a POI as a daily one. It's not a lower bar. It's a different clock.

Six types, not two

The design was a pair per session: ASH/ASL (Asia High/Low), LSH/LSL (London), NYSH/NYSL (New York). A session field carries which killzone the level belongs to, independently of the type string.

SESSION_POI_TYPES: Dict[str, str] = {
    "ASH": "Asia Session High",   "ASL": "Asia Session Low",
    "LSH": "London Session High", "LSL": "London Session Low",
    "NYSH": "New York Session High", "NYSL": "New York Session Low",
}

Every one of the six ends in exactly two characters — SH or SL — so direction logic never branches six ways. It's a suffix check:

def is_session_high_type(poi_type): return poi_type in SESSION_POI_TYPES and poi_type.endswith("SH")
def is_session_low_type(poi_type):  return poi_type in SESSION_POI_TYPES and poi_type.endswith("SL")
 
def session_poi_opposite_type(poi_type: str) -> str:
    """The sibling level of the same session — 'NYSH' -> 'NYSL'."""
    return poi_type[:-2] + ("SL" if poi_type.endswith("SH") else "SH")

That last function matters more than it looks — it's how the entry context later answers "has the other side of this range already been swept."

The window that decides whether a level exists yet

Killzone math looks trivial until you have to decide, at any arbitrary moment, which window is "the last completed one." Asia crosses midnight. And if the current moment is inside a killzone, the window that just closed is yesterday's, not today's.

def last_completed_killzone_window_utc(session, now):
    """Returns (window_start, window_end, next_window_start) for the most
    recently COMPLETED killzone window. next_window_start is also the
    level's natural expiry — the start of the next same-session window."""

At 00:30 UTC, mid-Asia-session, the function correctly returns yesterday's Asia window as the last completed one — and its next_window_start (23:00 UTC today) is already in the past relative to "the next Asia session," which tells the worker: don't create this level, it's stale. That guard is what stops the worker from resurrecting a dead level the moment its own session starts again.

I deliberately used the same killzone windows the platform already uses for per-session PnL stats and RAG grouping (trading_sessions.py), not the DST-aware full-session boundaries from the position-management post. Different job, different clock: position management needs to know when institutional desks are opening and closing; a session liquidity POI needs to match the exact window the rest of the platform calls "Asia" when it reports your session-by-session edge. (This is exactly the decision the update below reverses: full DST-aware sessions turned out to be the right window for a level a trader reads off a chart, and the "match the PnL/RAG grouping" rationale here was abandoned with it — the killzone windows still power that grouping, just not these POIs anymore.)

How long does a session level stay alive?

This was the one open design question I sat with before writing any code: does Asia's low stay valid only until London closes, or all the way until the next Asia session starts?

I went with the wider window — a level lives until the start of the next same-session window, roughly 24 hours. Asia's low stays a legitimate target through both London and New York, mirroring how PDH/PDL already behaves (a daily level is valid all day, not just for the next few hours). The alternative — expiring at the next session's close — would have quietly cut off exactly the "NY sweeps Asia's low" setups that are some of the better session-liquidity plays.

def invalidate_expired_session_poi(poi, now=None) -> dict:
    """Level-triggered check: now >= expires_at. Robust to bot downtime —
    doesn't require catching the exact session-boundary moment."""

expires_at is written once, at creation, as the next window's start timestamp. The check is a plain comparison run on every worker cycle — not an edge-triggered "did we just cross midnight" event. If the bot is down when a boundary passes, the very next tick after restart still catches it and expires the level correctly. Redis TTL backs it up as a second line of defense, set to expires_at plus a margin.

One invalidation core instead of three copies

invalidate_poi_pdl_pdh and invalidate_poi_pwh_pwl were, before this, two ~180-line functions that differed in exactly one thing: which direction counted as a breakout. Adding a third near-identical function for session levels was the wrong move, so the first actual commit here was a refactor, not a feature:

def invalidate_single_level_poi(
    poi, executor, break_direction, level_label,
    validate_tf="60", require_double_close=True,
    max_wait_ltf_candles=10, entry_model_calls=None,
) -> dict:
    """break_direction: +1.0 = high-type level (close above invalidates),
    -1.0 = low-type, None = timeout-only (no price-based invalidation)."""

invalidate_poi_pdl_pdh and invalidate_poi_pwh_pwl became thin wrappers over this — same signatures, same call sites in both trade managers, zero regression risk. Session levels call the same core directly with a 15-minute validation timeframe and a shorter timeout window, because an intraday level shouldn't sit around waiting hours for confirmation the way a weekly one can.

Three invalidation paths (clean breakout close, displacement impulse after a first close, and timeout-without-confirmation) now have exactly one implementation instead of three copies drifting slowly apart.

What the entry agent actually sees

This is the part that doesn't exist for PDH/PDL, and it's the reason session liquidity got its own module instead of just reusing the daily-level code path.

A bare price level is a weak signal on its own. What makes a session sweep tradeable — in the ICT sense — is everything around the sweep: was it a clean stop-hunt with a fast reclaim, or did price just keep going? Has the other side of the range already been taken, meaning the dealing range is done? Is this level stacked on top of a daily or weekly liquidity pool? Did the raid happen inside the textbook Judas Swing window?

None of that should be left to the LLM to eyeball from raw candles. It's all computable, so it's all computed — in core/poi/session_context.py, before the model ever sees the setup:

### SESSION LIQUIDITY CONTEXT (ASL — Asia Low)
- Level: 61234.5 (Asia session low, 2026-07-08, window 23:00–02:00 UTC)
- Session range: 61234.5 – 62100.0 (1.41%, 0.5x avg of last 20 Asia ranges,
  session direction: bearish (open 62050.0 → close 61300.0))
- Sweep: 06:42 UTC during London session, 4h42m after Asia close,
  depth 0.18% below level, price reclaimed level: YES (SFP)
- Current session: London (84 min in), entry inside killzone: YES
- Range position: price at 8.0% of Asia range — discount (below equilibrium) 61667.25
- Opposite level (Asia High 62100.0): untouched — one-sided raid,
  external liquidity on the other side intact
- HTF liquidity confluence: PDL 61180.0 (-0.1%)
- Daily open (00:00 UTC): 62500.0 — price -1.9% vs open
- Pattern: JUDAS SWEEP (Asia low raided within 90 min of London open)

(This example reflects the original killzone-windowed design — the 23:00–02:00 UTC window and the field name inside_killzone. The update below moved the window to the full Asia session (~00:00–07:00 UTC), renamed the field to inside_session, and changed what "current session" means — the shape of the block is unchanged.)

Every line there is a fact, not an inference. reclaimed: YES comes from comparing the last closed 5-minute candle's close against the level after the sweep — not from asking the model to eyeball a chart. range_vs_avg comes from a rolling Redis list of the last 20 Asia range sizes, so the model knows whether today's range is unusually tight (often the setup for an expansion day) without having to remember yesterday's numbers. The opposite_level block tells it whether this is a one-sided raid or whether the dealing range is already complete — which changes the entire read of the setup from "reversal in progress" to "range is done, expect chop."

The Judas Sweep flag is the one I like most, because it turns a named ICT pattern into a boolean instead of a hope that the model recognizes it from prose: it's True only when the session's extreme got swept within 90 minutes of the next session's open. No judgment call, no vibes — a timestamp comparison. (As originally shipped this compared against the killzone open; see the update below for the full-session version.)

The prompt's job, correspondingly, shrank to interpretation rules instead of computation: "reclaim = NO is a strong contraindication — that's acceptance, not a sweep. BOTH SIDES TAKEN means the range is done, downgrade continuation logic." The system prompt never computes a percentage or checks a clock. It reacts to numbers that are already sitting in the context.

Builder resilience mattered here too — every enrichment step (the HTF confluence lookup, the daily-open fetch, the sibling-level check) is wrapped so a failure just omits that one field instead of failing the whole entry evaluation. A setup should never get silently dropped because one enrichment API call timed out.

When the sweep is a threat, not a setup

Everything above treats a session sweep as an opportunity — the start of a possible entry. But the same event has a second reading: if you're already long and price just took out the New York session high above you, your position didn't just make progress. It ran straight into a pool of freshly harvested liquidity — exactly the kind of level where reversals against you like to start.

The platform already had a channel for this. Whenever any POI activates against an open position's direction, an opposite-POI check fires and — depending on your management mode — either takes deterministic protective action or wakes the Sentinel/Guard agents for a review. When I went to wire session levels into it, I found they were already wired: session POIs activate through the same code path as PDH/PDL, so the trigger had existed from the moment the entry integration landed. A long gets reviewed when a session high is swept, a short when a session low goes. Once per level's ~24-hour lifetime, no extra cooldown machinery needed. The directional semantics fell out of the existing design for free.

What wasn't free was the context. The management agents were getting a trigger string — opposite_poi_ash_session — and nothing else. All of that deterministic SMC/ICT material from the previous section existed, but it was only being injected into the entry prompt. The agent deciding whether to defend your position was flying on the level's name alone.

So the sweep now ships a descriptor to the management agents, built by the same module that feeds the entry agent — same facts, different framing:

  • Sweep freshness is the field that matters most here and least at entry time. An entry evaluation happens minutes after a sweep, once LTF structure confirms. A management review fires seconds after it — before a single 5-minute candle has closed. Whether price will reclaim the level or accept beyond it is genuinely unknowable at that moment, and the agent is told so explicitly: that uncertainty is precisely why the default lean is protective rather than terminal.
  • The Judas flag and both-sides-taken state carry over unchanged — a raid early in the next session is the statistically reversal-prone kind, which for the position under review is an argument for banking something.
  • The lean is PROTECT, never EXIT. Partial take-profit, stop to the last swing or breakeven — sized by the position's own RR and structure. A session sweep alone is never grounds for a full close, for the same reason a bias flip isn't: it's context, not a verdict. If the trade's thesis explicitly targets liquidity beyond the swept level, holding through the sweep is a legitimate read, and the agent is allowed to make it.

The deterministic mode needed no changes at all — it already took an RR-tiered partial and trailed the stop on any opposite-POI activation. The gap was purely in what the reasoning mode could see. That asymmetry is worth noticing: the dumb path was fine, the smart path was starving.

The naming mistake I made twice

This part isn't flattering, but it's the honest build-in-public version of events.

First pass: SSH/SSL, mirroring PDH/PDL/PWH/PWL. Made sense on paper — Session High, Session Low.

First collision: the entry prompts already used SSL for something completely different — Sell-Side Liquidity, standard ICT shorthand sitting right next to BSL (Buy-Side Liquidity) in the mandatory liquidity-sweep-analysis section. Same three letters, two unrelated meanings, in the same document the model reads on every single entry evaluation. Renamed to SH/SL.

Second collision, worse than the first: bare SL is Stop Loss. Not adjacent terminology — the exact same abbreviation, used six times in the very prompt file where I'd just placed the new POI type, in a section about user-provided execution levels. A prompt disambiguation note ("this SL means Session Low, not Stop Loss — check context") is a patch, not a fix, and I didn't trust it to hold up once the model is juggling a dozen other things in a long ReAct loop.

The actual fix wasn't a better disambiguation sentence. It was removing the possibility of the collision entirely: fold the session into the type itself. NYSL cannot be mistaken for Stop Loss. ASH cannot be mistaken for Sell-Side Liquidity. There's no two-letter abbreviation left to collide with anything, because there's no bare two-letter abbreviation anymore.

The lesson generalizes past this feature: when a short code collides with existing vocabulary, the fix usually isn't a longer explanation next to the short code — it's making the short code less short, in the specific direction that kills the ambiguity. SL needed one more character of information (which session), not one more sentence of caveat.

Update: the radar UI shipped, and the window moved to full sessions

The feature sat behind its opt-in flag for exactly as long as it took to build the one thing "Where this fits" originally said was missing: a way to actually see these levels. Wiring that up surfaced two real bugs, and it's also what finished a migration that the original build had left half done.

The radar UI. POI radar (both the standalone page and the main terminal's chart) now has a session selector — independent toggles for Asia, London, New York, sitting next to the existing POI/TF/POS/ORD layer toggles. Session levels live under a SESSION pseudo-interval in Redis, decoupled from whatever candle interval the chart happens to be showing, so picking a session overlays it on top of any timeframe — 1m or 1W, doesn't matter. Selecting a session also draws two dashed vertical lines, start and end, reusing the exact same canvas-primitive architecture (BoxPrimitive) already drawing every other POI zone on the chart — a new VerticalLinePrimitive sibling class, same lifecycle, same coordinate-resolution logic, just drawing a line instead of a box.

Bug one: the lines were two hours off. The worker writes session_start/session_end as datetime.isoformat() on a naive (tz-less) UTC datetime — a string like "2026-07-07T23:00:00", no Z, no offset. new Date(...) on a timezone-less ISO string is parsed as local browser time, not UTC. Every viewer in a different timezone would see the boundary lines drawn at a different, wrong offset from the true UTC time — silently, no error, just a chart that looked subtly off unless you happened to check the math. Fixed by forcing UTC interpretation at the two places that parse these fields, rather than touching the backend's naive-datetime convention (used everywhere else too, including PDH/PDL — changing it there risked naive vs aware datetime comparison crashes in code that's assumed naive for years).

Bug two, the one that actually mattered: default-allow. Per-user POI-type eligibility (user_matches_poi()) has always worked as opt-out — a POI type absent from a user's active_pois config defaults to enabled. That's fine for types that shipped enabled from day one. It is very much not fine for a type that's supposed to be opt-in: since no user had ever configured session_liquidity (the account-settings toggle didn't exist yet), every account would have silently traded session sweeps the moment the worker created its first level — completely bypassing the Redis feature flag, which only ever gated creation, never execution eligibility. Fixed with an explicit check, evaluated unconditionally (even for users with no active_pois config at all), that defaults this one composite type to blocked instead of inheriting the general opt-out fallback. The account page also got its missing toggle — off by default, same as everything else here.

Killzone vs. session. Putting real boundary lines on a real chart made a design choice from the original build visibly wrong for what it implied. LONDON END landing at 09:00 UTC reads like London closed for the day — it didn't; that's the ICT killzone, the narrow opening-volatility window, not the market's actual trading hours (roughly 07:00–16:00 UTC). The killzone hours were the right choice for one thing they were built to match — the platform's existing per-session PnL/RAG grouping — but the wrong mental model for "here's the range this level came from," which is what a trader looking at a chart actually wants, and the intent from day one was always for a session-liquidity level to track the session a trader would recognize on a chart.

So the window that decides a level's high/low moved from the narrow killzone to the full session, reusing session_boundaries.py — the same DST-aware Tokyo/London/NY open-close boundaries already driving the position-management boundary triggers, rather than inventing a third set of hour constants:

def last_completed_full_session_window_utc(session, now):
    """Same (window_start, window_end, next_window_start) contract as the
    killzone version, but Asia = Tokyo open -> London open, London = London
    open -> close, New York = NY open -> close — DST-aware, market-native."""

Two things fell out of this that the killzone version never had to deal with. Asia's full session (Tokyo 09:00 JST open to London's ~07:00–08:00 UTC open) sits entirely inside one UTC calendar day — it no longer crosses midnight, which was the one genuinely fiddly part of the original killzone math. And London and New York now overlap — 12:00–16:00 UTC is inside both sessions simultaneously, which real trading sessions do and killzones, by construction, never did. The candle lookback also had to grow: full sessions run up to nine hours instead of three, so the worst case for "how far back could the last completed window start" went from ~24h to ~33h — the 15-minute candle fetch went from 120 candles (30h) to 150 (37.5h) to keep a margin.

Also picked up along the way: session liquidity is now explicitly skipped for Bybit equity_perps symbols. Asia and London killzones-turned-sessions don't map onto NYSE hours, and the worker was iterating over every enabled Bybit asset class without that filter — a gap that predates this update but only became obvious once someone asked "why would a US stock have a London session."

That covered where a level's range comes from. It didn't yet cover when a sweep of that level happenedsession_context.py's sweep-timing side (record_session_sweep_facts, the "current session" display, Judas Sweep detection) is asking the same underlying question, "what session is this," just at a different moment: not "what window sized this level" but "what session is now, or was swept_at, in." Migrating the window and migrating the clock are really one job, and finishing it meant tracing what happened once the two hadn't been done in the same pass:

  • A London-session sweep at, say, 10:00 UTC — solidly inside London's real 07:00–16:00 hours — was still classified as sweep_session: "Off-Hours", because 10:00 falls in none of the old three-hour killzone buckets. The rendered context would tell the entry agent "sweep during Off-Hours session," which the system prompt is explicitly told to treat as low quality — actively working against a perfectly good London-session setup.
  • The Judas Sweep flag compared swept_at against the killzone open of whatever sweep_session string it got — so once sweep_session was wrong, the flag's timestamp math was anchored to the wrong open, or to no open at all for an "Off-Hours" attribution. A pattern built to be a clean boolean was silently returning False for raids that were, by the platform's own definition of "session," genuinely Judas sweeps.

One concept — "what session is this" — needs one implementation, not two clocks that happen to agree only as long as nobody changes either one. The fix was to give the full-session side its own classification function and point every session-liquidity timing computation at it, level-sizing and sweep-timing alike:

def classify_full_session(moment: datetime) -> str:
    """Which full-session window `moment` falls in — Asia/London/New York/
    Off-Hours — using the same DST-aware boundaries the worker sizes levels
    from. During the London/NY overlap (~12:00-16:00 UTC) this returns
    New York: once NY opens it reads as the dominant session, matching how
    ICT commentary treats the overlap as NY price action taking over."""
 
def full_session_open_at_or_before_utc(session: str, moment: datetime) -> datetime:
    """Start of the full-session window active at `moment` (or the most
    recently closed one) — replaces the killzone-hour arithmetic that used
    to answer 'how far into this session are we' and 'when did the next
    session open, for Judas timing'."""

Both live in session_boundaries.py, next to the worker's own window function, instead of a fourth clock invented specifically for this. session_context.py no longer imports anything killzone-shaped — sweep_session, the now.current_session block (renamed inside_killzoneinside_session, since it's no longer measuring a killzone), and the Judas comparison all run through classify_full_session() now. The killzone-hour utilities in trading_sessions.py didn't go anywhere — they're still exactly right for the PnL/RAG session-by-session stats they were built for, which is a genuinely different, unrelated consumer. Only the session-liquidity feature stopped reaching for them.

The London/NY overlap needed an explicit call: attribute it to New York. A sweep at 13:00 UTC is inside both sessions' real hours simultaneously, and the code has to pick one string for sweep_session. NY-dominant matches how the overlap actually trades — and it makes the Judas math work out cleanly too, since a London-session extreme raided shortly after NY opens (which, under full sessions, happens hours before London itself closes) still reads as "swept during New York, N minutes after NY's open" exactly like the original design intended, just anchored to the right open.

Three of the fourteen original window-creation tests had hardcoded killzone-hour assumptions baked into their fixtures and broke outright when the worker moved to full sessions; they got rewritten, plus five new tests for the window-math function itself, including one for the London/NY overlap the killzone version structurally couldn't produce. The sweep-timing side needed the same treatment once its own migration caught up: ten tests across the two files touching sweep timing got new fixture timestamps to match the full-session boundaries — the numbers moved, the assertions' shape didn't. All of it stayed behind the same still-disabled flag throughout.

Where this fits

Every session-liquidity POI still runs through the exact seven-state machine every other POI does — ACTIVE on creation, PENDING on price touch, READY only after LTF ChoCH and displacement confirm, and so on. Nothing about the state machine changed. What's new is entirely upstream of it: a worker that decides which levels exist and for how long, and a context builder that gives the entry agent SMC/ICT-grade reasoning material instead of a bare number.

The feature is fully wired — worker, invalidation, entry-loop integration, scoring, LLM context, position-management sweep trigger, radar visualization, and a per-user opt-in toggle — with every session-timing computation now agreeing on what a session actually is, and still sitting behind an opt-in flag, off by default, for a stretch of demo-account observation before it starts placing real orders. Six extra liquidity magnets a day is a meaningful increase in surface area, and I'd rather watch it work quietly — and correctly, now that it's actually visible and internally consistent — before it does.

ShareX / TwitterThreads
sc4mp avatar

Public pen name of LiquidMind's founder and builder. Writing first-hand engineering notes and transparent performance reviews from the system's internal ledger.

Stay Liquid

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

Loading discussion…