Engineering10 min read

Build in Public: The market has a clock — so should the bot

The bot was reviewing open positions when candles closed, when structure broke, when news hit, when BTC flipped. But it had no concept of market rhythm — sessions opening, liquidity rotating, institutional desks clocking in and out. Here's how we wired five session boundary events into the Triad pipeline, and why getting the times right turned out to be less obvious than it looks.

By

position-managementsessionsarchitecturetimezoneDSTbuild-in-publicrisk-managementscheduler

In brief

  • Five session boundary events now trigger a position review: Asian open, London open, NY open, London close, NY close — each with a different default lean for the AI agent.
  • Session times are computed from market-local conventions in Asia/Tokyo, Europe/London, and America/New_York, so there are no hardcoded Poland-time offsets.
  • The trigger follows the same Triad pipeline as every other event — Global Sentinel + Personal Guard — and execution respects each user's declared autonomy mode.
  • A shared session_boundaries module keeps the scheduler and session manager on the same DST-aware source of truth.

The position management engine had good coverage of market events: an opposing POI appearing, a favorable structure break, a BTC daily bias reversal, a high-impact news kill zone. What it didn't have was any concept of market rhythm.

It didn't know that London just opened. It didn't know NY was about to close. It didn't know that the low-liquidity transition into the Asian session was 20 minutes away.

Those things matter. They change the environment every open position is sitting in.

Why sessions are not just a display feature

In most trading apps, the session display is decorative — a badge in the corner that says "LONDON" in case you forgot where you were. That's not what I wanted to build.

The honest reason sessions matter for position management is liquidity regime change. When London opens, institutional desks come online and often sweep the highs and lows of the Asian session before committing to a direction. When NY closes at 17:00 ET, the market transitions into overnight Asian conditions — lower depth, wider spreads, gap risk. An open position that's fine at 16:00 ET is in a different risk environment at 18:00 ET.

These aren't subtle shifts. They're the dominant cause of the situations where "the trade was working and then something happened overnight." The bot should be aware of them.

So I wired five boundaries into the position management pipeline:

Boundary Local market time Approx. PL
Asian open 09:00 JST ~02:00 (summer) / ~01:00 (winter)
London open 08:00 London ~09:00
NY open (overlap) 08:00 ET ~14:00; 13:00 during US/EU DST gaps
London close 17:00 London ~18:00
NY close 17:00 ET ~23:00; 22:00 during US/EU DST gaps

Each one is a named trigger event: session_start_asian, session_start_london, session_start_ny, session_end_london, session_end_ny. Each one, when fired, fans out to every open position across every symbol and every user — same fan-out pattern as the bias flip trigger.

Why five events, not six

The Asian session ends around the same time London opens. That overlap is intentional: I didn't create a session_end_asian trigger because there's nothing unique to say at that moment that session_start_london doesn't already capture. Asian end is subsumed by London open. Four sessions, five meaningful transition points.

The agent doesn't treat all boundaries equally

The session context hits the AI Sentinel as an additional block in its prompt, similar to how the bias flip block works. But the default lean injected into that block is different for each boundary — because the risk profile of each transition is genuinely different:

Asian open (session_start_asian) — NEUTRAL. The Asian session is quiet, usually ranging. Unless the position's own structure is already broken, the default is to hold and watch.

London open (session_start_london) — REVIEW. This is the highest-priority boundary. London smart money frequently sweeps the Asian session's range before committing to direction. A stop sitting just above/below the Asian high/low is in danger. The agent checks whether the stop is far enough from the sweep zone.

NY open (session_start_ny) — REVIEW. The NY-London overlap is peak liquidity. The session either confirms the London move or sharply reverses it. This is where you decide whether to trail a working trade or protect against an exhaustion move.

London close (session_end_london) — NEUTRAL to PROTECTIVE. Institutional desks unwind positions. A trade that's been running with London can give back gains as those desks exit. A partial or a trailing stop before this point costs less than riding the unwinding.

NY close (session_end_ny) — PROTECTIVE. This is the most protective boundary. The market is about to enter overnight Asian conditions. Gap risk, spread spikes, low depth. The default lean is to move SL to breakeven or trail behind last swing. The cost of holding through the overnight is higher than the opportunity cost of a tighter stop.

The agent always weighs this context against the position's own structure and PnL situation. "London is closing" is never an automatic reason to act — but it's context the Sentinel didn't have before.

The part that looked simple and wasn't

Here's where the implementation got interesting.

The existing session manager used rough Poland-time ranges: Asian from 01:00, European from 08:00, overlap from 14:00, and American from 17:00. Copying those values into the scheduler would have looked simple.

It would also have encoded several different errors into a clock-driven trigger.

The problem is that Poland's clocks and the market's clocks don't always move together. The US changes on the second Sunday in March and first Sunday in November; Poland and the UK use the last Sundays of March and October. In 2026 that creates a three-week gap in spring and a one-week gap in autumn. During those gaps, 08:00 ET is 13:00 PL, not 14:00 PL.

More concretely, the corrected times (in PL) are:

  • Asian open is ~02:00 PL summer / ~01:00 PL winter (Tokyo = UTC+9, no DST; the shift is entirely in Poland's clock)
  • London open stays at ~09:00 PL — both PL and UK shift together
  • NY open is normally ~14:00 PL, but 13:00 PL during the US/EU DST gaps
  • London close stays at ~18:00 PL
  • NY close is normally ~23:00 PL, but 22:00 PL during the same gaps

Tokyo and New York are different kinds of outliers. Tokyo never changes its clock, so its Poland-time equivalent moves seasonally. New York does change its clock, but not on the same dates as Europe, so its Poland-time equivalent moves temporarily during the transition gaps. London remains stable relative to Poland under the current rules because the UK and Poland change on the same dates.

The fix is to not use Poland time at all for the computation. Instead:

## session_boundaries.py — the shared source of truth for session times
def get_session_boundaries_utc(date_utc=None):
    tokyo_tz  = pytz.timezone("Asia/Tokyo")
    london_tz = pytz.timezone("Europe/London")
    ny_tz     = pytz.timezone("America/New_York")
 
    if date_utc is None:
        date_utc = datetime.now(pytz.UTC)
 
    y, m, d = date_utc.year, date_utc.month, date_utc.day
 
    def _to_utc(tz, hour, minute):
        return tz.localize(datetime(y, m, d, hour, minute)).astimezone(pytz.UTC)
 
    return [
        ("session_start_asian",  _to_utc(tokyo_tz,  9,  0)),
        ("session_start_london", _to_utc(london_tz, 8,  0)),
        ("session_start_ny",     _to_utc(ny_tz,     8,  0)),
        ("session_end_london",   _to_utc(london_tz, 17, 0)),
        ("session_end_ny",       _to_utc(ny_tz,     17, 0)),
    ]

tz.localize() applies the timezone database rules for the requested date — GMT or BST for London, EST or EDT for New York, and fixed UTC+9 for Tokyo. The code stores the market-local session conventions, not Poland-time offsets.

The scheduler compares now_utc against each boundary_utc in UTC seconds. It never fires early: a boundary is eligible from its exact timestamp until 30 minutes afterward. That catch-up window handles a brief restart or event-loop stall without turning an old boundary into a stale trigger.

One module, multiple consumers

The boundary computation is in a dedicated module: core/trading/session_boundaries.py. Both the scheduler (which fires the position-management triggers) and the TradingSessionManager (which classifies the current moment as "asian", "european", "overlap", "american", or "off_market" for the UI and risk multiplier) now import from the same place.

Before this change, TradingSessionManager used hardcoded Poland-time hour ranges:

if 1 <= current_hour < 8:
    current_session = 'asian'
elif 8 <= current_hour < 14:
    current_session = 'european'
elif 14 <= current_hour < 17:
    current_session = 'overlap'
elif 17 <= current_hour < 24:
    current_session = 'american'

The European session starting at 08:00 PL (London opens at 09:00 PL), the overlap going to 17:00 PL (London actually closes at 18:00 PL, not 17:00) — these were rough approximations that accumulated small errors across every subsystem that relied on them.

After the change, TradingSessionManager calls get_current_session(now_utc) from the shared module, which derives the category from actual boundary UTC datetimes:

## session_boundaries.py
def get_current_session(now_utc=None):
    b = get_session_boundaries_utc_dict(now_utc)
    asian_open, london_open, ny_open, london_close, ny_close = (
        b["session_start_asian"], b["session_start_london"],
        b["session_start_ny"], b["session_end_london"], b["session_end_ny"],
    )
    if asian_open <= now_utc < london_open:   return "asian"
    if london_open <= now_utc < ny_open:      return "european"
    if ny_open <= now_utc < london_close:     return "overlap"
    if london_close <= now_utc < ny_close:    return "american"
    return "off_market"

The session display in the UI, the risk multiplier calculation, and the position-management trigger detection now derive their weekday categories from the same boundaries. The low-liquidity period between NY close and Tokyo open remains explicitly classified as off_market instead of being relabeled as asian.

Deduplication and safeguards

Session detection runs in its own lightweight 30-second worker, independent of candle analysis. That separation matters: an expensive analysis cycle can take minutes, but it can no longer delay the session clock.

Deduplication uses two Redis records with different jobs. A token-owned, 10-minute processing lock prevents concurrent workers from starting the same boundary and cannot be released by a worker that no longer owns it. A completion key (session_dispatch:{boundary_date}:{trigger}, TTL 26 hours) is written only after every symbol review succeeds. If a review fails, the lock is released and the next clock tick can retry. If Redis is unavailable, the dispatcher fails closed rather than risking duplicate actions.

The full deduplication stack:

Layer Mechanism
Session clock Dedicated 30 s worker, 30 min post-boundary catch-up window
Session dispatch Token-owned Redis processing lock, 10 min TTL
Completed dispatch Redis completion key, 26 h TTL
Global agent mark_event_processed_once() per POI per trigger
POI lock global_pos_mgmt_lock:{poi_id}

The completion marker gives at-most-once successful dispatch per boundary date. Failed attempts remain retryable, while the lower POI-level guards protect already-completed work if a partial multi-symbol run has to be retried.

Weekends are skipped entirely — institutional session rhythm breaks down over the weekend, and the Asian session's hourly character changes enough that the boundary events would be noise rather than signal.

What the user sees

Nothing — unless the agent decides something is worth acting on.

For an Autonomous user whose position has a stop sitting in the Asian range sweep zone at London open: the SL gets moved. A trail, a small partial, something to reduce the exposure to the sweep. They see the notification: "SL moved to last swing — trigger: session_start_london."

For an Approval user in the same situation: a confirmation prompt arrives. Nothing happens until they tap.

For any user with a healthy, de-risked position where nothing structurally warrants action: silence. The boundary check ran. The agent concluded HOLD. The user's morning is unbothered.

The restraint is deliberate. A 6am ping that says "it's London open!" and then does nothing useful is worse than no feature at all. The bar for action is the same as every other trigger: the agent has to find a reason in the position's own structure and PnL situation, not just in the session clock.

Architecture in context

This trigger joins the market-driven paths in LiquidMind's event-driven position management system:

  1. opposite_poi_{type}_{interval} — threat signal, opposing zone detected
  2. opposite_event_detected — a new opposing LTF BOS or ChoCH threatens the position thesis
  3. favorable_event_detected — structure break in your favor, trail the stop
  4. mcb_momentum_signal_{interval}_{signal} — momentum changed and must be revalidated against fresh data
  5. bias_flip_{direction}_1D — BTC daily bias reversed, covered in the previous post
  6. forex_news_kill_zone:{events} — high-impact USD news window
  7. session_start/end_{session} — trading session boundary (this post)

Every trigger feeds the same Triad: Global Sentinel reads market reality and emits a stance, Personal Guard maps the stance to a concrete action for the user's specific position, and execution follows the user's declared autonomy mode. One pipeline, multiple entry points.

The session boundary trigger is the only one of these that runs entirely on the clock — no LLM inference required to detect it. Detection is deterministic: timezone-aware UTC boundaries, a post-boundary catch-up window, and a weekday check. The LLM only runs after the boundary is confirmed, to evaluate the position in that context. That's the right division: let the clock know when a session starts, let the agent decide what to do about it.

ShareX / TwitterThreads

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.