//+------------------------------------------------------------------+
//|  OathraSync.mq5                                                  |
//|  Oathra MT5 EA v4.0 — Sync + Trade Manager (single EA)           |
//|                                                                  |
//|  Single EA combining trade/account sync with the Trade Manager   |
//|  (multi-TP, trailing stops, 16 command types). Replaces the      |
//|  previous OathraSync + OathraManager pair so users only install  |
//|  one file.                                                       |
//|                                                                  |
//|  SETUP:                                                          |
//|  1. MT5: Tools → Options → Expert Advisors                       |
//|     → Allow WebRequest for listed URL                            |
//|     → Add: https://api.oathra.app                                |
//|  2. Get your API key: Oathra Settings → API Key                  |
//|  3. Attach this EA to any chart (symbol/TF don't matter)         |
//|                                                                  |
//|  HISTORY                                                         |
//|  v1.1 — On first launch per account, sends full deal history    |
//|          so the equity chart is immediately populated.           |
//|  v2.0 — MFE/MAE computed server-side. EA only sends raw data.   |
//|  v3.0 — Command execution: EA polls for commands every 1s.      |
//|          Trade Manager + Smart Alert actions via Railway.        |
//|          MT5 push notifications for alert names.                 |
//|  v3.1 — BUGFIX: HistorySelectByPosition() corruption,           |
//|          same-second deal skip, OnTrade throttle,                |
//|          StringToCharArray null terminator in HTTP body.         |
//|  v3.2 — BUGFIX: open_trade now uses sl_pips/tp_pips with proper |
//|          GetPipSize() conversion. Added close_ticket and         |
//|          modify_sltp commands.                                   |
//|  v4.0 — Trade Manager merged in. Multi-TP tracker (up to 150    |
//|          levels), trailing stops (points/ATR/profit_pct/be+),    |
//|          16 command types incl. open_pending, modify_pending,    |
//|          partial_close, close_by_magic, set_tp_levels,           |
//|          trailing_start. Command polling 500ms (was 1000ms).     |
//|          Brand rename RawTrade→Oathra. GlobalVariable prefix     |
//|          OA_* (was RT_*) — first run after upgrade re-uploads    |
//|          full deal history (server upserts, harmless).           |
//|  v4.20 — 37-bug fix sweep:                                      |
//|          Critical safety: magic-filter on every TM close handler |
//|            (no more wiping other EAs' positions); TP-side       |
//|            validation; lot-floor (no clamp-up to broker min);   |
//|            TP / trailing latches only set after broker confirm. |
//|          Pipeline integrity: string-aware JSON scanner (escape  |
//|            and depth honoring); confirm-retry queue so chained  |
//|            set_tp_levels/trailing_start don't lose ticket data; |
//|            BuildOpenJSON keys on POSITION_IDENTIFIER (matches   |
//|            BuildClosedJSON); GetField* honors explicit zero;    |
//|            CmdStartTrailing reads param1 by method, not first   |
//|            nonzero; ConfirmCommand cmdId escaped.               |
//|          Networking: exponential backoff on consecutive failures|
//|            (30s/60s/2m/5m/10m); 401 + URL-not-allowed surfaced  |
//|            once via Alert; response read uses actual length     |
//|            (no buffer-tail garbage); daily prune moved to timer.|
//|          Cursor & state: per-batch cursor advancement (failed   |
//|            uploads no longer skip data); broker-aware GV keys   |
//|            (login + FNV hash of broker company); state file     |
//|            switched to UTF-16 with v2 + ANSI fallback; SaveTM   |
//|            on OnDeinit; GetTickCount → GetTickCount64 (no 49.7d |
//|            overflow).                                           |
//|          TP/trailing/orders: stop-limit pending (price_limit    |
//|            arg wired); CmdSetTPLevels accepts tp1..tp150 with   |
//|            auto-balanced percentages; CmdOpenMarket fill-price  |
//|            fallback uses live quote; m_trade magic reset after  |
//|            per-call SetExpertMagicNumber; trailing PnL includes |
//|            POSITION_SWAP; SymbolSelect before symbol-info reads.|
//|          Reconcile/perf: BuildClosedJSON + ResolvePositionRisk  |
//|            no longer per-deal HistorySelect (was O(N²) for      |
//|            multi-thousand-deal accounts); SyncAll deferred to   |
//|            first OnTimer (no OnInit hang); reconcile catches up |
//|            after offline gaps; equity baseline seeded from      |
//|            current balance minus visible PnL (was 0).           |
//|          Polish: EnableAlerts input; comment 31-char truncate;  |
//|            ReconcileHour/Min input clamping; pip size trusts    |
//|            SYMBOL_TRADE_TICK_SIZE for crypto/indices; removed   |
//|            hardcoded is_prop_firm field (server has truth).     |
//|  v4.1 — Review fixes (13 items):                                |
//|          • Closed-only batches always go to /sync/trades-bulk   |
//|            (prevents stale-mark of legitimate open trades).      |
//|          • Lot/direction/split_count validation in handlers.    |
//|          • Partial-close deals skipped while position still     |
//|            open (no more posId conflict in DB).                  |
//|          • Composite (close_time, deal_ticket) sync cursor —    |
//|            same-second deal no longer resends every tick.       |
//|          • CmdSetTPLevels: broker TP only cleared on success.   |
//|          • OnTick TP: triggered flag only set on successful     |
//|            partial close (will retry on requote).                |
//|          • ConfirmCommand returns structured result with        |
//|            ticket / tickets[] / fill_price / lots_filled —      |
//|            unblocks Trade Manager multi-TP + trailing chain.     |
//|          • ExtractJsonBool helper; force_close now read.        |
//|          • iATR handles cached per (symbol, period).            |
//|          • TM state (multi-TP, trail, lock) persisted to        |
//|            MQL5/Files/oathra_tm_state_<login>.json across       |
//|            EA reload — no more silent loss of risk mgmt.        |
//|          • New start_trading command unlocks stop_trading.      |
//|  v4.21 — Broker symbol catalog upload.                          |
//|          • BuildSymbolsHash() folds a canonical short-form line |
//|            per symbol (volume_min/step/max + tick + stops_level |
//|            + trade_mode + swap) over SymbolsTotal(false) — full |
//|            broker tree, NOT Market Watch — so two EAs on the    |
//|            same broker server produce the same hash.            |
//|          • /sync/account now carries symbols_hash and           |
//|            server_tz_offset_minutes. Railway compares the hash  |
//|            against its stored truth for the (broker, server)    |
//|            profile and queues a dump_symbols command via        |
//|            trade_commands when they differ. EA stays a dumb     |
//|            pipe — no comparison, no decision logic.             |
//|          • New dump_symbols command uploads the full catalog    |
//|            (spec + sessions for each symbol) in 200-symbol      |
//|            chunks via /sync/symbols/{begin,chunk,commit}.       |
//|            Each chunk ≈ 80-150KB JSON, well under the 2MB body  |
//|            limit. Sessions read via SymbolInfoSession{Quote,    |
//|            Trade} for days 0-6, idx 0-3 — works for any symbol  |
//|            without polluting Market Watch.                      |
//|          • Sleep(1) every 50 symbols during enumeration so      |
//|            OnTimer isn't starved on terminals with thousands    |
//|            of broker symbols.                                   |
//|          • Idempotent commit: server-issued upload_id, atomic   |
//|            commit step, replay-safe.                            |
//|  v4.25 — Sync now, history reach, ordinal symbol hash.            |
//|          • New sync_now command runs the full SyncAll() pipeline  |
//|            (account, capital flows, trades, pending orders) on    |
//|            demand instead of waiting for the next OnTimer tick,   |
//|            then confirms like any other command.                  |
//|          • /sync/account now reports history_from — the earliest  |
//|            deal this terminal's history holds — omitted (never a  |
//|            literal 0, which is a real unix time) when there are   |
//|            no deals yet. capabilities.partial_whole flips to      |
//|            true: whole-position aggregation (v4.24) is live.      |
//|          • BuildSymbolsHash split into a broker-dependent line    |
//|            collector and the pure, exported SymbolsHashOfLines(), |
//|            which sorts ordinally (its own StringGetCharacter      |
//|            loop, never StringCompare, whose order can vary by     |
//|            Windows locale) so the hash is byte-identical to the   |
//|            monitor's JavaScript reimplementation of the sort.     |
//|          • Heartbeat JSON now built by the pure, exported          |
//|            HeartbeatJson(), same "own bytes, not a hand-typed      |
//|            guess" pattern as v4.23's BuildResultJson.              |
//|  v4.22 — Account-link oath kill-switch.                         |
//|          • Detects HTTP 423 oath_required from monitor's        |
//|            requireAccountLinkOath middleware (returned when the |
//|            user revoked their Data & Metrics Oath in the app).  |
//|          • Surfaces a single Alert/Print pointing the user to   |
//|            the Oathra app, then pauses sync for 30 min so the   |
//|            journal does not fill with retries while the user    |
//|            re-takes the oath.                                   |
//|          • Auto-clears on the next successful sync (re-take     |
//|            naturally flips trading_accounts.status back via     |
//|            the migration-122 trigger).                          |
//|  v4.23 — Account-scope protective close + per-position result.  |
//|          • close_all/close_symbol/close_profitable/close_losing/|
//|            close_by_magic and stop_trading's force-close now    |
//|            read payload.scope (via ExtractNestedJson+           |
//|            ExtractJsonString — never GetFieldStr, which is not  |
//|            depth-aware). scope=="account" bypasses IsOathraOwned |
//|            so a Guardian/kill-switch close reaches every open   |
//|            position; every ordinary Trade Manager close keeps   |
//|            the ownership gate exactly as before.                |
//|          • CmdResult gains positions[] (position_id as a string |
//|            — POSITION_IDENTIFIER read before PositionClose,     |
//|            since the position vanishes from PositionsTotal()    |
//|            the instant it closes) and scope_honoured, both      |
//|            emitted by BuildResultJson on every command so the   |
//|            server can tell an updated terminal from a stale one.|
//|          • Single-ticket handlers (close_ticket, modify_sltp,   |
//|            partial_close) are unchanged — still ownership-gated.|
//|  v4.23.2 — Nested-key authority-forgery fix.                      |
//|          • FindKey now tracks bracket depth (like                 |
//|            FindStructural already did) so a "scope"/"magic"/      |
//|            "force_close" match only counts as a DIRECT CHILD      |
//|            of the object being searched, never a descendant       |
//|            one or more levels deeper. Closes 05-review-1.md       |
//|            Finding 1: {"foo":{"scope":"account"}} used to         |
//|            fool honourAccountScope; it no longer does.            |
//|  v4.24 — Capability report on every heartbeat.                    |
//|          • /sync/account now carries a "capabilities" object      |
//|            (hardcoded literals for what this build of the EA can  |
//|            do: account_scope_close, position_result true;         |
//|            partial_whole, stopout_levels, fill_prices,             |
//|            symbol_spread false) plus magic_filter and trade_mode  |
//|            (demo/contest/real from ACCOUNT_TRADE_MODE). Additive   |
//|            fields only — an older monitor ignores keys it does    |
//|            not know, and an unreported capability reads as "no",  |
//|            never as a silent yes.                                 |
//|  v4.26 — Symbol upload JSON: pure builder + NaN-safe swap.        |
//|          • BuildSymbolJson split into a broker-dependent collector |
//|            and the pure, exported BuildSymbolJson(SymbolSpec),     |
//|            same "own bytes, not a hand-typed guess" pattern as     |
//|            every other exported JSON region.                      |
//|          • swap_long/swap_short now write JSON null instead of a   |
//|            raw NaN when the broker reports one (Islamic/swap-free  |
//|            symbols) — a literal NaN token is not valid JSON at     |
//|            all, and was the cause of some of the 85 live /chunk    |
//|            failures this fixes (L6: not reported, never zero).     |
//|          • EscapeJson marked as its own exported region so the     |
//|            pure builder above can call it without reintroducing a  |
//|            broker dependency.                                      |
//|  v4.26 (A07) - Stop-out levels, safe opens, press-time close.      |
//|          • Heartbeat carries margin_so_call / margin_so_so /       |
//|            margin_so_mode; margin_level is sent as null while no   |
//|            margin is in use (a flat account has no margin level).  |
//|          • Capabilities stopout_levels, open_safety and            |
//|            close_opened_before now report true.                    |
//|          • open_market: slippage per order from                    |
//|            max_deviation_points (fallback 20); refuses             |
//|            spread_too_wide over max_spread_points and quote_stale  |
//|            over max_quote_age_s; below_min_lot answers min_lots;   |
//|            a partial fill confirms partial, filled_lots and        |
//|            requested_lots instead of reading as a full fill.       |
//|          • close_all with opened_before_ms (UTC epoch ms) leaves   |
//|            a position that opened more than 5 s after the press    |
//|            and lists it in skipped_opened_after. The server clock  |
//|            offset is rounded to 15 min so a quiet symbol's stale   |
//|            last tick cannot shift it.                              |
//|          • A TP-chained move to break-even lands be_offset_pips in |
//|            the trade's favour; trailing moves only in steps of     |
//|            step_points (persisted as "step").                      |
//|  v4.27 - Broker facts in the catalog upload.                     |
//|          • Each symbol also carries tick_value_profit,           |
//|            tick_value_loss, trade_calc_mode, margin_hedged,      |
//|            spread_points, spread_float and swap_days (Sunday to  |
//|            Saturday). A fact the terminal cannot read is sent as |
//|            null, never 0; a tick value of 0 (what the terminal   |
//|            answers for a symbol it has not loaded) is null too.  |
//|          • /begin also carries margin_mode, the raw integer of   |
//|            ACCOUNT_MARGIN_MODE; the monitor names it.            |
//|          • Every older key and its formatting is unchanged, so   |
//|            the catalog hash does not move.                       |
//|  v4.28 - The server sets the sync speed.                         |
//|          • The SyncInterval input is gone. The EA starts at 5 s  |
//|            and adopts sync_interval_seconds from every           |
//|            /sync/account answer (Paid 5 s, Free 30 s).           |
//|          • A cadence 429 holds the next heartbeat for exactly    |
//|            retry_after_ms, then syncs. A limiter 429             |
//|            (sync_rate_limited) pauses /sync only, never          |
//|            /commands.                                            |
//|          • Only network errors and 5xx arm the global backoff. A |
//|            426 pauses /sync alone. Another 4xx pauses only the   |
//|            endpoint that got it (escalating), so Guardian closes,|
//|            Sync now and the heartbeat keep arriving.             |
//|          • A trade event posts trades without a heartbeat and is |
//|            never dropped: one inside the 2 s throttle runs as    |
//|            soon as it ends.                                      |
//|          • Each heartbeat carries a request_id the monitor keys  |
//|            its replay cache on; closes found by the incremental  |
//|            cursor are sent with announce:true.                   |
//|          • sync_now restarts the periodic timer and reports      |
//|            failure when its heartbeat was not accepted.          |
//|          • history_from is rescanned only when the deal count    |
//|            changes, not on every heartbeat.                      |
//|  v4.29 - The catalog hash no longer moves with the market.       |
//|          • BuildSymbolsHash() leaves SYMBOL_TRADE_TICK_VALUE out |
//|            of its canonical line. For a symbol whose profit      |
//|            currency is not the account's, that value is an       |
//|            exchange rate: it changed between the hash pass and   |
//|            the upload, so the monitor refused every commit on a  |
//|            trading day (422), and the hash sent on the heartbeat |
//|            was a new one each time, so the monitor asked again.  |
//|          • The value is still uploaded with each symbol. Only    |
//|            the hash stops reading it.                            |
//|  v4.30 - One refused key no longer stops sync.                   |
//|          • A 401 used to pause all sync until the EA was         |
//|            restarted. It now rides the normal backoff and sync   |
//|            resumes at the next success; the key Alert waits for  |
//|            three 401s in a row.                                  |
//|          • Queued command confirms are unescaped when reloaded   |
//|            from disk; before, the monitor refused them (400) on  |
//|            every retry. A confirm refused with a 4xx other than  |
//|            401/408/423/429 now leaves the queue.                 |
//|  v4.31 - The speed follows the account.                          |
//|          • The server names two speeds on every /sync/account    |
//|            answer: sync_open_seconds with a position open,       |
//|            sync_flat_seconds with none (Paid 1 s and 30 s, Free  |
//|            30 s). They are clamped to [1, 300]; an answer        |
//|            without them uses sync_interval_seconds for both.     |
//|          • A trade event sends its trades, then the heartbeat    |
//|            right after, so balance and equity move at once.      |
//|          • A fast cycle sends the heartbeat and the positions;   |
//|            capital flows and pending orders go at most every 5 s |
//|            (and on every trade event and Sync now).              |
//|  v4.32 - Waits for the account, reports every balance deal.      |
//|          • While MetaTrader is not connected to the broker, or   |
//|            has not loaded the account (balance and equity 0, no  |
//|            position, no history), the heartbeat says it is       |
//|            waiting (terminal_waiting) instead of sending its     |
//|            figures as a reading, and the rest of the cycle       |
//|            waits. A 200, so nothing backs off.                   |
//|          • Credit, charge, correction, bonus, commission-type,   |
//|            interest, dividend and tax deals go to                |
//|            /sync/balance-deals with their own cursor, never as   |
//|            deposits. The server can then explain a balance move  |
//|            to the cent.                                          |
//|  v4.33 - Account-scope close of one trade, every pending order.  |
//|          • close_ticket reads payload.scope the depth-aware way  |
//|            (as close_all). With "account", granted by the server |
//|            from a Trading Rules break, it closes the trade even  |
//|            when Oathra did not open it; without, the ownership   |
//|            gate stands exactly as before. It now answers like    |
//|            the close family: scope_honoured, one positions[]     |
//|            line, and the broker's retcode, so the server tells   |
//|            full, partial, already gone and refused apart.        |
//|          • delete_pending reads the scope the same way: with     |
//|            "account" any pending order may go, without it only  |
//|            one Oathra placed. One positions[] line (the order    |
//|            ticket as position_id) and the retcode.               |
//|          • New delete_all_pending: every pending order (account  |
//|            scope) or every one Oathra placed, one line each.     |
//|          • capabilities report ticket_scope_close and            |
//|            pending_scope_cancel true.                            |
//|          • ResetCmdResult/BuildResultJson, AppendPositionResult  |
//|            and the new result builders are exported regions for  |
//|            CommandResultGolden.mq5.                              |
//|          • Smart Alerts (the wiring pass, 2026-10-28): each open |
//|            trade reports its symbol's spread_points, point and   |
//|            digits, and capabilities report symbol_spread true.   |
//|            The symbols the heartbeat answer's watch_symbols name |
//|            (at most 20) are quoted on every heartbeat after it:  |
//|            bid, ask, the tick's time and age, spread, point and  |
//|            digits, or unknown_symbol / no_quote. The list is kept|
//|            until an answer replaces it, and empty after a restart.|
//+------------------------------------------------------------------+
#property copyright "Oathra"
#property link      "https://oathra.com"
#property version   "4.33"
#property strict
#property description "Oathra Sync + Trade Manager"
#property description "One EA: trade/account sync, multi-TP, trailing stops, remote commands."

#include <Trade\Trade.mqh>
#include <Trade\PositionInfo.mqh>
#include <Trade\OrderInfo.mqh>
#include <Trade\SymbolInfo.mqh>

//--- Inputs
input string OathraURL      = "https://api.oathra.app";  // Railway service URL (no trailing slash)
input string APIKey         = "";                         // Your API Key from Oathra Settings
input int    MagicFilter    = 0;                          // Sync-side magic filter (0 = sync all EAs)
input bool   EnableCommands = true;                       // Enable remote command execution
input int    ReconcileHour  = 0;                          // Daily reconciliation hour (0-23, server time)
input int    ReconcileMin   = 0;                          // Daily reconciliation minute (0-59)
input bool   EnableAlerts   = true;                       // Show modal Alert() popups for alert-tagged commands
input bool   DebugLog       = false;                      // Enable verbose logging

//--- Hard limits and tunables
#define EA_VERSION              "4.33"  // single source — keep #property version in sync
#define DEFAULT_DEVIATION_POINTS 20     // slippage when a ticket sets none (v4.26: per-order override)
#define CLOSE_PRESS_TOLERANCE_MS 5000   // v4.26: a press-time close leaves only positions opened later than this after the press
#define HTTP_401_ALERT_AFTER     3      // v4.30: 401s in a row before the key Alert fires
#define MIN_SYNC_SECONDS         5      // v4.28: the server sets the cadence; these only clamp what it sends
#define DEFAULT_SYNC_SECONDS     5      // until the first /sync/account answer names the plan's interval
#define BALANCE_DEALS_BATCH      1000   // v4.32: balance deals per /sync/balance-deals post (the server takes up to 5000)
#define MAX_SYNC_SECONDS         300
#define MAX_WATCH_SYMBOLS        20     // v4.33: the most symbols a heartbeat quotes (the server sends at most 20)
#define MIN_FAST_SYNC_SECONDS    1      // v4.31: the open/flat speeds may go down to 1 s
#define FULL_SYNC_MIN_MS         5000   // v4.31: capital flows and pending orders at most this often
#define CMD_POLL_MS              500    // Poll for commands every 500ms (was 1000ms in v3.x)
#define TRADE_EVENT_THROTTLE_MS  2000   // OnTrade event sync throttle
#define TM_MAGIC_BASE            777000 // Default magic for TM-opened trades
#define MAX_TRAILING             50     // Max concurrent trailing-stop configs
#define MAX_TP_LEVELS            150    // Max concurrent multi-TP entries
#define MAX_RETRY                2      // Order-send retry on requote/price_changed
#define MAX_TM_TICKETS           500    // Max TM-opened ticket history in g_TmOpenedTickets

//--- Trade objects (used by TM command handlers)
CTrade        m_trade;
CPositionInfo m_pos;
COrderInfo    m_order;
CSymbolInfo   m_sym;

//--- Multi-TP tracking
struct TPLevel
{
   ulong  ticket;
   double tp_price;
   double close_pct;      // % of remaining lots to close on hit
   double move_sl_to;     // move SL here on hit (0 = leave SL)
   bool   triggered;
   int    direction;      // 1=buy, -1=sell
};
TPLevel g_TP[];
int     g_TPCount = 0;

//--- Trailing-stop tracking
struct TrailConfig
{
   ulong  ticket;
   int    method;         // 0=points, 1=ATR, 2=profit_pct, 3=be_plus
   double param1;          // distance/period/pct/offset
   double param2;          // ATR multiplier
   double highestPnl;      // for profit_pct method
   bool   activated;       // for be_plus: one-shot trigger flag
   int    direction;
   string symbol;
   double step_points;     // v4.26 (B54): smallest SL move, in points (0 = any move over one point)
};
TrailConfig g_Trail[];
int         g_TrailCount = 0;

//--- Pass 2 Sol 2: Ticket history — every ticket opened by a TM command is
//--- recorded here so IsOathraOwned() covers positions with non-standard magic
//--- (user-supplied custom magic) that don't match TM_MAGIC_BASE.
ulong g_TmOpenedTickets[];
int   g_TmOpenedTicketsCount = 0;

//--- Structured command result. ConfirmCommand serializes this into the
//--- /commands/confirm body so the app can read ticket / fill_price /
//--- tickets[] for chained set_tp_levels + trailing_start commands.
//--- Non-trade-opening actions leave the numeric fields at 0; only the
//--- message field is meaningful for them.
//--- v4.33: the struct and its two functions are one exported region,
//--- carved for tools/mt5/tests/CommandResultGolden.mq5.
// @export-begin CommandResultJson
struct CmdResult
{
   string message;
   ulong  ticket;          // first opened ticket (0 if none)
   string tickets_csv;     // "12345,12346" — all opened tickets, no brackets
   double fill_price;      // realized fill (or pending price for open_pending)
   double lots_filled;     // total lots actually filled across splits
   string positions_json;  // one JSON object per position a close touched, comma-joined,
                           // no brackets — see AppendPositionResult. "" when none.
   bool   scope_honoured;  // true only when this close ran with payload.scope=="account"
                           // and the ownership gate was bypassed for it (GUARDIAN.md §6.2).
                           // False on every other command, including a normal, gated close.
   // v4.26 (A07-C15). Each is emitted by BuildResultJson only when set, so
   // every older result body is byte-for-byte unchanged.
   string code;            // why an open was refused: spread_too_wide, quote_stale, below_min_lot
   double min_lots;        // with below_min_lot: the smallest total the ticket can send
   bool   partial;         // an open filled less than it asked for
   double requested_lots;  // with partial: what the open asked for (lots_filled is what it got)
   string skipped_opened_after_csv; // quoted position ids a press-time close left open
   // v4.33: the broker's return code for a command on one trade or one order
   // (close_ticket, delete_pending), emitted only when set: the server's
   // settle reads a failure's reason from it.
   int    retcode;
   bool   retcode_set;
};

void ResetCmdResult(CmdResult &r)
{
   r.message         = "";
   r.ticket          = 0;
   r.tickets_csv     = "";
   r.fill_price      = 0.0;
   r.lots_filled     = 0.0;
   r.positions_json  = "";
   r.scope_honoured  = false;
   r.code            = "";
   r.min_lots        = 0.0;
   r.partial         = false;
   r.requested_lots  = 0.0;
   r.skipped_opened_after_csv = "";
   r.retcode         = 0;
   r.retcode_set     = false;
}

string BuildResultJson(const CmdResult &r)
{
   string json = "{\"message\":\""        + EscapeJson(r.message) + "\""
        + ",\"ticket\":"           + IntegerToString(r.ticket)
        + ",\"tickets\":["         + r.tickets_csv + "]"
        + ",\"fill_price\":"       + DoubleToString(r.fill_price, 5)
        + ",\"lots_filled\":"      + DoubleToString(r.lots_filled, 2)
        + ",\"positions\":["       + r.positions_json + "]"
        + ",\"scope_honoured\":"   + (r.scope_honoured ? "true" : "false");
   if(StringLen(r.code) > 0)
      json += ",\"code\":\"" + r.code + "\"";
   if(r.code == "below_min_lot")
      json += ",\"min_lots\":" + DoubleToString(r.min_lots, 8);
   if(r.partial)
      json += ",\"partial\":true,\"filled_lots\":" + DoubleToString(r.lots_filled, 8)
            + ",\"requested_lots\":" + DoubleToString(r.requested_lots, 8);
   if(StringLen(r.skipped_opened_after_csv) > 0)
      json += ",\"skipped_opened_after\":[" + r.skipped_opened_after_csv + "]";
   if(r.retcode_set)
      json += ",\"retcode\":" + IntegerToString(r.retcode);
   return json + "}";
}
// @export-end

//--- ATR handle cache (issue #12). iATR creates a new indicator per call;
//--- on a fast-tick instrument with several trailing positions this would
//--- thrash the indicator engine. Cache by (symbol, period); release in
//--- OnDeinit.
struct AtrHandle { string sym; int period; int handle; };
AtrHandle g_AtrCache[];

int GetAtrHandle(const string sym, const int period)
{
   for(int i = 0; i < ArraySize(g_AtrCache); i++)
      if(g_AtrCache[i].sym == sym && g_AtrCache[i].period == period)
         return g_AtrCache[i].handle;
   int h = iATR(sym, PERIOD_CURRENT, period);
   if(h == INVALID_HANDLE) return INVALID_HANDLE;
   int n = ArraySize(g_AtrCache);
   ArrayResize(g_AtrCache, n + 1);
   g_AtrCache[n].sym    = sym;
   g_AtrCache[n].period = period;
   g_AtrCache[n].handle = h;
   return h;
}

void ReleaseAtrCache()
{
   for(int i = 0; i < ArraySize(g_AtrCache); i++)
      if(g_AtrCache[i].handle != INVALID_HANDLE)
         IndicatorRelease(g_AtrCache[i].handle);
   ArrayResize(g_AtrCache, 0);
}

// FNV-1a 32-bit hash. MT5 has no built-in string hash; we just need a
// short stable digest of the broker name to disambiguate two accounts
// that happen to share a login number.
uint StringHashSimple(const string s)
{
   uint h = 2166136261;
   int  n = StringLen(s);
   for(int i = 0; i < n; i++)
      h = (h ^ (uint)StringGetCharacter(s, i)) * 16777619;
   return h;
}

// @export-begin SymbolsHashOfLines
// Ordinal (code-unit) string comparison — never StringCompare, whose order
// can vary with the Windows locale MetaTrader inherits. A04-C10 asserts
// the monitor's JavaScript hash equals this hash byte for byte, and
// JavaScript's default string comparison is already ordinal, so the sort
// order here must be too or the two would silently disagree on any
// non-ASCII symbol name.
int OrdinalCompareStrings(const string a, const string b)
{
   int lenA = StringLen(a);
   int lenB = StringLen(b);
   int n = (lenA < lenB) ? lenA : lenB;
   for(int i = 0; i < n; i++)
   {
      ushort ca = StringGetCharacter(a, i);
      ushort cb = StringGetCharacter(b, i);
      if(ca < cb) return -1;
      if(ca > cb) return 1;
   }
   if(lenA < lenB) return -1;
   if(lenA > lenB) return 1;
   return 0;
}

// In-place insertion sort using the ordinal comparator above. MT5 has no
// built-in sort for string arrays usable on dynamic-sized buffers;
// insertion sort is fast enough for 1500-5000 symbols and keeps the hash
// deterministic across EAs and Windows locales.
void SortStringArray(string &arr[])
{
   int n = ArraySize(arr);
   for(int i = 1; i < n; i++)
   {
      string key = arr[i];
      int j = i - 1;
      while(j >= 0 && OrdinalCompareStrings(arr[j], key) > 0)
      {
         arr[j + 1] = arr[j];
         j--;
      }
      arr[j + 1] = key;
   }
}

// Pure: sorts (ordinally) and hashes a canonical symbol-catalog line set.
// Every value comes from the lines array, never a broker/SymbolInfo
// lookup — this is what C51's harness compiles standalone into
// HashGolden.mq5 to prove the EA's own bytes produce this hash, and what
// A04-C10 re-runs unchanged to assert the monitor's JS reimplementation
// matches it byte for byte. FNV-1a 32-bit over the sorted lines, each
// newline-terminated so no line's own content can smear into the next.
long SymbolsHashOfLines(string &lines[])
{
   SortStringArray(lines);
   uint h = 2166136261;
   int  kept = ArraySize(lines);
   for(int i = 0; i < kept; i++)
   {
      string l = lines[i] + "\n";
      int n = StringLen(l);
      for(int k = 0; k < n; k++)
         h = (h ^ (uint)StringGetCharacter(l, k)) * 16777619;
   }
   return (long)h;
}
// @export-end

// Collects the broker-dependent per-symbol lines (SymbolInfo* lookups —
// deliberately NOT part of the export region above, which must never call
// broker functions itself) and hands them to the pure SymbolsHashOfLines
// for sorting + hashing. Iterates SymbolsTotal(false) — full broker list,
// NOT Market Watch — so two EAs on the same broker server produce the same
// hash regardless of each user's personal Market Watch selection.
// Per-symbol canonical short-form covers the fields most likely to drive
// trade rejection (lot specs + tick size + stops + trade_mode + swap).
// Cached in g_SymbolsHash; refreshed on first call and at the start of
// every CmdDumpSymbols.
//
// v4.29: every field here is a contract fact, the same for every account
// on the server until the broker changes it. SYMBOL_TRADE_TICK_VALUE is
// not one (for a cross pair it is the live rate into the account's own
// currency), so it is not hashed. The monitor's computeCanonicalHash
// (services/symbolConsensus.js) builds the same eleven fields.
string BuildSymbolsHash()
{
   int total = SymbolsTotal(false);
   string lines[];
   ArrayResize(lines, total);
   int kept = 0;

   for(int i = 0; i < total; i++)
   {
      string sym = SymbolName(i, false);
      if(StringLen(sym) == 0) continue;
      lines[kept++] = sym + "|"
         + IntegerToString((int)SymbolInfoInteger(sym, SYMBOL_DIGITS))           + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN),         6)   + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP),        6)   + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX),         2)   + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE),    10)  + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE), 2)  + "|"
         + IntegerToString((int)SymbolInfoInteger(sym, SYMBOL_TRADE_STOPS_LEVEL)) + "|"
         + IntegerToString((int)SymbolInfoInteger(sym, SYMBOL_TRADE_MODE))        + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_SWAP_LONG),          6)   + "|"
         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_SWAP_SHORT),         6);
      if(kept % 50 == 0) Sleep(1);   // yield CPU on large catalogs
   }
   ArrayResize(lines, kept);

   long hash = SymbolsHashOfLines(lines);
   return StringFormat("%08X", (uint)hash);
}

// Cached symbol-catalog digest. Computed once per upload cycle.
string g_SymbolsHash = "";
int    g_SymbolsCount = 0;

// Copy a legacy GlobalVariable to its new key and delete the old one.
// No-op if the old key isn't present or the new key already has a value.
void MigrateLegacyKey(const string oldKey, const string newKey)
{
   if(!GlobalVariableCheck(oldKey)) return;
   if(GlobalVariableCheck(newKey))
   {
      GlobalVariableDel(oldKey);
      return;
   }
   GlobalVariableSet(newKey, GlobalVariableGet(oldKey));
   GlobalVariableDel(oldKey);
}

//--- Global state
int      g_SyncSec;          // v4.28: set by the server (sync_interval_seconds), never by an input
int      g_SyncOpenSec;      // v4.31: with a position open (sync_open_seconds)
int      g_SyncFlatSec;      // v4.31: with none (sync_flat_seconds)
ulong    g_LastFullSyncMs;   // v4.31: the last cycle that also sent flows and pending orders
bool     g_HeartbeatNow;     // v4.31: a trade event asks for the heartbeat at once
ulong    g_SyncHoldUntilMs;  // v4.28: a cadence 429's retry_after_ms; 0 = no hold
ulong    g_SyncBlockedUntilMs; // v4.28: /sync/* paused (limiter 429, 426); /commands unaffected
string   g_EpBlockName[];      // v4.28: a /sync endpoint paused on its own by another 4xx
ulong    g_EpBlockUntilMs[];   //        until when
int      g_EpBlockFailures[];  //        its consecutive 4xx, escalates its pause
bool     g_TradeEventPending;  // v4.28: OnTrade inside the 2 s throttle, run by OnTimer when it ends
ulong    g_LastTradeEventMs;
int      g_LastHttpCode;       // v4.28: the last Post's HTTP code (0 = not sent)
ulong    g_HeartbeatSeq;       // v4.28: request_id sequence
bool     g_Http426Alerted;
int      g_HistFromDeals = -1; // v4.28: deal count the cached history_from was scanned at
datetime g_HistFrom      = 0;
int      g_ReconcileHour;    // clamped 0-23 in OnInit
int      g_ReconcileMin;     // clamped 0-59 in OnInit
string   g_AccountKey;       // OA_LastClosed_<login>     — last closed-deal sync time
string   g_LastDealKey;      // OA_LastClosedDeal_<login> — last closed-deal ticket at cursor
string   g_HistKey;          // OA_HistSynced_<login>     — first-launch history flag
string   g_ReconcileKey;     // OA_LastReconcile_<login>  — last reconcile date
string   g_CommandExecPrefix;// OA_CmdExec_<login>_       — command-id markers
string   g_WholePosKey;      // OA_WholePos_v1_<login>    — one-time whole-position resend flag (B3, ruling d/f)
string   g_FlowsCursorKey;   // OA_FlowsCursor_<login>    — last capital-flow deal time synced
string   g_FlowsDealKey;     // OA_FlowsDeal_<login>      — …and that deal's own ticket (composite cursor)
string   g_BalDealsCursorKey;// OA_BalDeals_<login>       — v4.32: last other-balance deal time synced
string   g_BalDealsDealKey;  // OA_BalDealsDeal_<login>   — …and that deal's own ticket (composite cursor)
string   g_WatchSymbols[];       // v4.33: the symbols the server asks quotes for (watch_symbols)
string   g_WaitingReason = "";// v4.32: why the last heartbeat waited ("not_connected", "not_loaded"), "" when it did not
string   g_AccountID;
datetime g_LastClosed;
ulong    g_LastClosedDeal;   // composite-cursor sibling of g_LastClosed
ulong    g_LastSyncMs;       // GetTickCount64 — wraps in 584M years instead of 49.7d
ulong    g_LastCmdPollMs;
bool     g_TradingLocked;    // set by stop_trading command
// Pass 2 Sol 4: pending-order sync cadence counter.
// SyncPendingOrders runs every PENDING_SYNC_EVERY data-sync cycles instead of
// every tick to bound the per-cycle cost for users with many pending orders.
// At the default value 1 it fires on every SyncAll cycle (including the first),
// i.e. once per g_SyncSec seconds. Raise it to throttle further.
#define PENDING_SYNC_EVERY  1     // fire every N SyncAll cycles
int      g_PendingSyncCycle = 0;  // cycle counter

//--- Confirm-retry queue (bug S5). A successful command's result is
//--- needed by the app to chain set_tp_levels / trailing_start. If the
//--- /commands/confirm POST fails (network blip), the body is queued
//--- and retried on each OnTimer tick. If the server re-sends the same
//--- cmdId (because it never got the original confirm), the queue is
//--- consulted before falling back to a dummy "Already executed" result.
//--- TODO(durability): this queue is RAM-only; an EA reload loses it.
//--- A future commit can persist each entry to a file in MQL5/Files/.
#define MAX_PENDING_CONFIRMS 50
struct PendingConfirm {
   string   cmdId;
   string   body;
   datetime queuedAt;
};
PendingConfirm g_PendingConfirms[];
int            g_PendingConfirmCount = 0;

//--- HTTP transport state (bug C7 / M14 / H4). Exponential backoff on
//--- consecutive failures stops the EA from hammering /commands/pending
//--- every 500ms during outages or auth misconfiguration. The "broken"
//--- flags surface 401 / "URL not allowed" once per session so users
//--- can fix the root cause instead of staring at a silent log.
ulong    g_HttpNextRetryMs = 0;     // GetTickCount64 — earliest next attempt
int      g_HttpFailures    = 0;     // consecutive failures (resets on success)
bool     g_HttpAuthBroken  = false; // v4.30: the 401 Alert has fired; cleared by the next success
int      g_Http401Count    = 0;     // v4.30: 401s in a row; cleared by the next success
bool     g_HttpUrlMissing  = false; // 4060 surfaced; URL not in MT5 allow list
bool     g_HttpOathRevoked = false; // 423 surfaced; sync paused 30 min
ulong    g_HttpOathRetryMs = 0;     // GetTickCount64 — earliest oath retry
datetime g_LastPruneDay    = 0;     // daily prune of OA_CmdExec_* markers
bool     g_InitialSyncPending = true; // first SyncAll runs from OnTimer, not OnInit

//+------------------------------------------------------------------+
int OnInit()
{
   if(StringLen(APIKey) < 10)
   {
      Alert("Oathra: API Key is not set. Go to EA Inputs and enter your API key.");
      return INIT_FAILED;
   }

   long   login    = AccountInfoInteger(ACCOUNT_LOGIN);
   string company  = AccountInfoString(ACCOUNT_COMPANY);
   string suffix   = IntegerToString(login) + "_" + StringFormat("%08X", StringHashSimple(company));
   g_AccountKey        = "OA_LastClosed_"     + suffix;
   g_LastDealKey       = "OA_LastClosedDeal_" + suffix;
   g_HistKey           = "OA_HistSynced_"     + suffix;
   g_ReconcileKey      = "OA_LastReconcile_"  + suffix;
   g_CommandExecPrefix = "OA_CmdExec_"        + suffix + "_";
   // B3/ruling d,f: one-time whole-position correction resend, gated so it
   // never repeats once every batch of it has confirmed.
   g_WholePosKey       = "OA_WholePos_v1_"    + suffix;
   // Ruling g: capital-flow (deposit/withdrawal) cursor, independent of the
   // closed-trades cursor above — flows and trades are different streams.
   g_FlowsCursorKey    = "OA_FlowsCursor_"    + suffix;
   g_FlowsDealKey      = "OA_FlowsDeal_"      + suffix;
   // v4.32: the other balance deals (credit, fees, bonuses, corrections...)
   // have their own cursor: they go to their own endpoint, apart from flows.
   g_BalDealsCursorKey = "OA_BalDeals_"       + suffix;
   g_BalDealsDealKey   = "OA_BalDealsDeal_"   + suffix;

   // Migrate legacy login-only keys → broker-hash-suffixed keys.
   // Two demo accounts with the same login number on different brokers
   // would otherwise collide in the GlobalVariable namespace.
   MigrateLegacyKey("OA_LastClosed_"     + IntegerToString(login), g_AccountKey);
   MigrateLegacyKey("OA_LastClosedDeal_" + IntegerToString(login), g_LastDealKey);
   MigrateLegacyKey("OA_HistSynced_"     + IntegerToString(login), g_HistKey);
   MigrateLegacyKey("OA_LastReconcile_"  + IntegerToString(login), g_ReconcileKey);

   g_LastClosed     = (datetime)(GlobalVariableCheck(g_AccountKey)
                       ? (long)GlobalVariableGet(g_AccountKey) : 0);
   g_LastClosedDeal = (ulong)(GlobalVariableCheck(g_LastDealKey)
                       ? (long)GlobalVariableGet(g_LastDealKey) : 0);
   g_AccountID      = "";
   g_LastSyncMs     = 0;
   g_LastCmdPollMs  = 0;
   g_TradingLocked  = false;

   // v4.28: the cadence is the server's. Start at 5 s; the first
   // /sync/account answer (or its 429) names the plan's interval.
   g_SyncSec              = DEFAULT_SYNC_SECONDS;
   g_SyncOpenSec          = DEFAULT_SYNC_SECONDS;
   g_SyncFlatSec          = DEFAULT_SYNC_SECONDS;
   g_LastFullSyncMs       = 0;
   g_HeartbeatNow         = false;
   g_SyncHoldUntilMs      = 0;
   g_SyncBlockedUntilMs   = 0;
   ArrayResize(g_EpBlockName, 0);
   ArrayResize(g_EpBlockUntilMs, 0);
   ArrayResize(g_EpBlockFailures, 0);
   g_TradeEventPending    = false;
   g_LastTradeEventMs     = 0;
   g_LastHttpCode         = 0;
   g_HeartbeatSeq         = 0;
   ArrayResize(g_WatchSymbols, 0);
   g_Http426Alerted       = false;
   g_HistFromDeals        = -1;
   g_HistFrom             = 0;

   // Out-of-range reconcile times silently skipped daily reconcile in
   // older versions (e.g. ReconcileHour=25 never matched dt.hour 0-23).
   g_ReconcileHour = (ReconcileHour < 0 || ReconcileHour > 23) ? 0 : ReconcileHour;
   g_ReconcileMin  = (ReconcileMin  < 0 || ReconcileMin  > 59) ? 0 : ReconcileMin;
   if(g_ReconcileHour != ReconcileHour)
      Print("Oathra: ReconcileHour ", ReconcileHour, " out of range 0-23, using 0");
   if(g_ReconcileMin != ReconcileMin)
      Print("Oathra: ReconcileMin ", ReconcileMin, " out of range 0-59, using 0");

   // Initialize TM trade objects + tracking arrays
   m_trade.SetExpertMagicNumber(TM_MAGIC_BASE);
   m_trade.SetDeviationInPoints(DEFAULT_DEVIATION_POINTS);
   m_trade.SetTypeFilling(ORDER_FILLING_IOC);
   ArrayResize(g_TP,               MAX_TP_LEVELS);
   ArrayResize(g_Trail,            MAX_TRAILING);
   ArrayResize(g_TmOpenedTickets,  MAX_TM_TICKETS);  // Pass 2 Sol 2
   g_TPCount               = 0;
   g_TrailCount            = 0;
   g_TmOpenedTicketsCount  = 0;

   // Single millisecond timer drives both command polling and data-sync gating.
   // PruneCommandExecutionMarkers is fired from OnTimer once per day so a
   // long-uptime terminal doesn't accumulate stale OA_CmdExec_* markers.
   EventSetMillisecondTimer(CMD_POLL_MS);

   // Restore TM state (multi-TP, trailing, trading-lock) from disk so an
   // EA reload doesn't silently drop the user's risk management.
   LoadTMState();
   // Pass 1 Sol 17: also restore any pending-confirm queue entries so
   // unconfirmed results (from positions opened before the reload) are
   // retried on the next OnTimer tick.
   LoadPendingConfirms();

   Print("Oathra: Initialized (v", EA_VERSION, "). Account: ", login,
         " Broker: ", AccountInfoString(ACCOUNT_COMPANY),
         " Sync every: set by Oathra (", g_SyncSec, "s until told)",
         " Cmd poll: ", CMD_POLL_MS, "ms",
         " Last closed sync: ", TimeToString(g_LastClosed));

   // Defer SyncAll to the first OnTimer tick. On accounts with thousands
   // of historical deals the snapshot+reconcile cycle could take 30+
   // seconds — long enough for MT5 to consider OnInit hung and unload
   // the EA. The initial-sync banner clears once the first sync finishes.
   g_InitialSyncPending = true;
   ulong nowMs = GetTickCount64();
   g_LastSyncMs    = 0;   // 0 forces immediate sync on first OnTimer tick
   g_LastCmdPollMs = nowMs;
   return INIT_SUCCEEDED;
}

//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
   // Final flush — every meaningful state change already saves, but a
   // graceful detach should close the loop in case an in-flight tick
   // mutated state and we exit before the next save would have fired.
   SaveTMState();
   EventKillTimer();
   ReleaseAtrCache();
}

//+------------------------------------------------------------------+
void OnTimer()
{
   ulong now = GetTickCount64();

   if(g_InitialSyncPending)
   {
      Comment("Oathra: initial sync in progress…");
      SyncAll();
      g_InitialSyncPending = false;
      g_LastSyncMs = now;
      Comment("");
   }

   // Drain any unconfirmed command results before polling for new ones —
   // ensures the app sees results for past commands the network ate
   // before any newly-arriving cmdIds touch the executed-marker path.
   if(StringLen(g_AccountID) > 0 && g_PendingConfirmCount > 0)
      RetryPendingConfirms();

   // Command polling — fast cadence, gated separately from data sync
   if(EnableCommands && StringLen(g_AccountID) > 0
      && (now - g_LastCmdPollMs) >= CMD_POLL_MS)
   {
      g_LastCmdPollMs = now;
      CheckCommands();
   }

   // Data sync — the server's cadence, independent of the command poll.
   // v4.28: after a cadence 429 the hold alone decides the retry, so a
   // refused heartbeat costs retry_after_ms, never a whole extra interval.
   // v4.31: the cadence is the open or the flat speed, and a trade event
   // asks for the heartbeat at once (the hold still wins).
   bool syncDue = (g_SyncHoldUntilMs > 0)
                  ? (now >= g_SyncHoldUntilMs)
                  : (g_HeartbeatNow || (now - g_LastSyncMs) >= (ulong)CurrentSyncSeconds() * 1000);
   if(syncDue)
   {
      g_SyncHoldUntilMs = 0;
      g_HeartbeatNow = false;
      g_LastSyncMs = now;
      SyncAll();
   }

   // v4.28: a trade event that arrived inside the 2 s throttle runs now.
   if(g_TradeEventPending && (now - g_LastTradeEventMs) >= TRADE_EVENT_THROTTLE_MS)
      SyncTradeEvent();

   // Daily reconciliation: fire any time AT OR AFTER the configured
   // hour:minute if today hasn't reconciled yet. Old behavior required
   // the EA to be running during the exact configured minute — an
   // offline gap (downtime, crash, restart) would silently skip that
   // day's reconcile.
   if(StringLen(g_AccountID) > 0)
   {
      MqlDateTime dt;
      TimeToStruct(TimeCurrent(), dt);

      double currentHHMM = dt.hour * 100.0 + dt.min;
      double targetHHMM  = g_ReconcileHour * 100.0 + g_ReconcileMin;
      if(currentHHMM >= targetHHMM)
      {
         double today      = (double)StringToInteger(StringFormat("%04d%02d%02d", dt.year, dt.mon, dt.day));
         double lastRecDay = GlobalVariableCheck(g_ReconcileKey)
                              ? GlobalVariableGet(g_ReconcileKey) : 0;
         if(today > lastRecDay)
         {
            ReconcileHistory();
            GlobalVariableSet(g_ReconcileKey, today);
         }
      }

      // Daily prune of OA_CmdExec_* markers (was OnInit-only — long-running
      // terminals would accumulate stale markers indefinitely).
      datetime today00 = StructToTime(dt) - dt.hour * 3600 - dt.min * 60 - dt.sec;
      if(today00 != g_LastPruneDay)
      {
         PruneCommandExecutionMarkers();
         g_LastPruneDay = today00;
      }
   }
}

//+------------------------------------------------------------------+
void OnTrade()
{
   // Reduced throttle (2s) ensures rapid closes are captured.
   // Server-side upsert handles duplicate sends.
   // v4.28: an event inside the throttle is kept, not dropped — OnTimer
   // runs it as soon as the 2 s pass, so a close never waits for the
   // next heartbeat.
   g_TradeEventPending = true;
   if((GetTickCount64() - g_LastTradeEventMs) >= TRADE_EVENT_THROTTLE_MS)
      SyncTradeEvent();
}

//+------------------------------------------------------------------+
// v4.28: a trade event posts the trades alone. It used to run SyncAll(),
// whose heartbeat a 5 s cadence refuses most of the time, and a refused
// heartbeat aborted the whole run, so the close waited for the next one.
// The heartbeat stays on the server's cadence; trades go now.
void SyncTradeEvent()
{
   g_TradeEventPending = false;
   g_LastTradeEventMs  = GetTickCount64();
   if(StringLen(g_AccountID) == 0)
   {
      SyncAll();
      return;
   }
   SyncCapitalFlows();
   SyncBalanceDeals();
   SyncTrades();
   SyncPendingOrders();
   // v4.31: the heartbeat follows on the next timer tick, so balance and
   // equity move with the trade. The server lets it through once.
   g_HeartbeatNow = true;
}

//+------------------------------------------------------------------+
// v4.31: the speed for this moment: the open speed while any position is
// open on the account, the flat speed otherwise.
int CurrentSyncSeconds()
{
   return (PositionsTotal() > 0) ? g_SyncOpenSec : g_SyncFlatSec;
}

//+------------------------------------------------------------------+
void OnTick()
{
   MonitorTradeManager();
}

//+------------------------------------------------------------------+
// Multi-TP level + trailing-stop monitoring. Runs every tick from OnTick,
// and also between batches of ReconcileHistory — a long daily reconcile
// blocks the (single) EA thread, so without this a TP / trailing level
// hit mid-reconcile would go unwatched for minutes.
// Cheap when g_TPCount + g_TrailCount are 0 (no TM commands active).
void MonitorTradeManager()
{
   bool tmStateChanged = false;

   // Multi-TP: partial-close and SL-move on hit
   for(int i = 0; i < g_TPCount; i++)
   {
      if(g_TP[i].triggered) continue;
      if(!PositionSelectByTicket(g_TP[i].ticket))
      {
         g_TP[i].triggered = true;  // Position closed externally
         continue;
      }

      string sym = PositionGetString(POSITION_SYMBOL);
      SymbolSelect(sym, true);   // ensure live quotes for off-chart symbols
      double price = (g_TP[i].direction > 0)
         ? SymbolInfoDouble(sym, SYMBOL_BID)
         : SymbolInfoDouble(sym, SYMBOL_ASK);

      bool hit = (g_TP[i].direction > 0)
         ? (price >= g_TP[i].tp_price)
         : (price <= g_TP[i].tp_price);
      if(!hit) continue;

      double remainingLots = PositionGetDouble(POSITION_VOLUME);
      double closeLots = NormalizeLots(sym, remainingLots * g_TP[i].close_pct / 100.0);

      // closeLots == 0 means the percentage rounds to nothing (e.g. 1%
      // of 0.05 lots on a min-step of 0.01) — treat as a no-op success.
      bool closeOk = (closeLots <= 0);
      if(closeLots > 0)
      {
         m_trade.SetTypeFilling(GetFillingMode(sym));
         closeOk = m_trade.PositionClosePartial(g_TP[i].ticket, closeLots);
         if(closeOk && DebugLog)
            Print("Oathra TM: TP hit, closed ", closeLots,
                  " lots on ticket ", g_TP[i].ticket);
         else if(!closeOk && DebugLog)
            Print("Oathra TM: TP partial close FAILED on ticket ", g_TP[i].ticket,
                  " (retcode ", m_trade.ResultRetcode(), ") — will retry next tick");
      }

      // Only chain the SL move + flag the level done when the close
      // actually executed. On failure, leave the level armed so the
      // next tick retries. The SL-move must also succeed before we
      // latch — otherwise a broker rejection on PositionModify would
      // permanently abandon the protective SL change requested by the
      // user (e.g. move SL to break-even after TP1).
      if(closeOk)
      {
         bool slMoveOk = true;
         if(g_TP[i].move_sl_to > 0 && PositionSelectByTicket(g_TP[i].ticket))
         {
            double currentTP = PositionGetDouble(POSITION_TP);
            slMoveOk = m_trade.PositionModify(g_TP[i].ticket, g_TP[i].move_sl_to, currentTP);
            if(!slMoveOk && DebugLog)
               Print("Oathra TM: TP-chained SL move FAILED on ticket ", g_TP[i].ticket,
                     " (retcode ", m_trade.ResultRetcode(), ") — will retry next tick");
         }
         if(slMoveOk)
         {
            g_TP[i].triggered = true;
            tmStateChanged = true;
         }
      }
   }

   // Trailing stops
   for(int i = 0; i < g_TrailCount; i++)
   {
      if(!PositionSelectByTicket(g_Trail[i].ticket)) continue;

      double currentSL = PositionGetDouble(POSITION_SL);
      double entry     = PositionGetDouble(POSITION_PRICE_OPEN);
      double currentTP = PositionGetDouble(POSITION_TP);
      double volume    = PositionGetDouble(POSITION_VOLUME);
      string sym       = g_Trail[i].symbol;
      // Some symbols may not be on the chart's Market Watch — selecting
      // here ensures SymbolInfoDouble below returns live values rather
      // than zeroing out and silently breaking trailing.
      SymbolSelect(sym, true);
      double point     = SymbolInfoDouble(sym, SYMBOL_POINT);
      double bid       = SymbolInfoDouble(sym, SYMBOL_BID);
      double ask       = SymbolInfoDouble(sym, SYMBOL_ASK);
      double price     = (g_Trail[i].direction > 0) ? bid : ask;
      // Pass 1 Sol 7: use GetNetPnl helper (profit+swap) for consistency with
      // CmdMoveSLBreakEven. POSITION_COMMISSION is not exposed on MT5 live
      // positions; commission is bundled into POSITION_PROFIT already.
      double pnl       = GetNetPnl(g_Trail[i].ticket);
      double newSL     = 0;

      switch(g_Trail[i].method)
      {
         case 0: // Points trailing
         {
            double dist = g_Trail[i].param1 * point;
            if(g_Trail[i].direction > 0)
            {
               double trail = price - dist;
               // >= entry: lock SL at the exact BE moment, not skip it
               if(trail > currentSL && trail >= entry) newSL = trail;
            }
            else
            {
               double trail = price + dist;
               // <= entry: same logic for SELL — lock at exact BE
               if((currentSL == 0 || trail < currentSL) && trail <= entry) newSL = trail;
            }
            break;
         }
         case 1: // ATR trailing
         {
            int    atrPeriod = (int)g_Trail[i].param1;
            double atrMult   = g_Trail[i].param2;
            int    atrHandle = GetAtrHandle(sym, atrPeriod);
            if(atrHandle == INVALID_HANDLE) break;
            double atrBuf[];
            ArraySetAsSeries(atrBuf, true);
            if(CopyBuffer(atrHandle, 0, 0, 1, atrBuf) <= 0) break;
            double atrDist = atrBuf[0] * atrMult;

            if(g_Trail[i].direction > 0)
            {
               double trail = price - atrDist;
               // >= entry: lock SL at the exact BE moment, not skip it
               if(trail > currentSL && trail >= entry) newSL = trail;
            }
            else
            {
               double trail = price + atrDist;
               // <= entry: same logic for SELL — lock at exact BE
               if((currentSL == 0 || trail < currentSL) && trail <= entry) newSL = trail;
            }
            break;
         }
         case 2: // Profit-percentage trailing (lock pct of peak PnL)
         {
            if(pnl > g_Trail[i].highestPnl) g_Trail[i].highestPnl = pnl;
            if(g_Trail[i].highestPnl <= 0) break;
            double lockPnl = g_Trail[i].highestPnl * g_Trail[i].param1 / 100.0;
            double tickVal = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
            double tickSize = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
            if(tickSize <= 0 || volume <= 0) break;
            double pipValue = tickVal / tickSize;
            double lockDist = lockPnl / (volume * pipValue);

            if(g_Trail[i].direction > 0)
            {
               double trail = entry + lockDist;
               if(trail > currentSL) newSL = trail;
            }
            else
            {
               double trail = entry - lockDist;
               if(currentSL == 0 || trail < currentSL) newSL = trail;
            }
            break;
         }
         case 3: // Break-even-plus offset (one-shot)
         {
            if(g_Trail[i].activated) break;
            double offset = g_Trail[i].param1 * point;
            if(g_Trail[i].direction > 0 && price > entry + offset)
               newSL = entry + offset;
            else if(g_Trail[i].direction < 0 && price < entry - offset)
               newSL = entry - offset;
            // activated flag is set below, only after PositionModify
            // confirms the broker accepted the BE move. Latching it here
            // would mark the level "done" even on a broker rejection,
            // permanently abandoning the protective SL move.
            break;
         }
      }

      if(newSL > 0)
      {
         int    digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
         newSL = NormalizeDouble(newSL, digits);
         // v4.26 (B54): a trail with step_points moves only by at least one
         // step. The first stop and the one-shot break-even move ignore it.
         double stepDist  = (g_Trail[i].method != 3 && currentSL > 0) ? g_Trail[i].step_points * point : 0;
         bool   bigEnough = (stepDist > 0) ? MathAbs(newSL - currentSL) >= stepDist - point / 2
                                           : MathAbs(newSL - currentSL) > point;
         if(bigEnough)
         {
            double dummy = 0;
            ValidateStopLevels(sym, price, newSL, dummy);
            if(m_trade.PositionModify(g_Trail[i].ticket, newSL, currentTP))
            {
               if(g_Trail[i].method == 3) g_Trail[i].activated = true;
               tmStateChanged = true;   // peak-PnL / activated may have shifted too
            }
            else if(DebugLog)
            {
               Print("Oathra TM: trailing PositionModify FAILED on ticket ", g_Trail[i].ticket,
                     " (retcode ", m_trade.ResultRetcode(), ") — will retry next tick");
            }
         }
      }
   }

   int beforeTP    = g_TPCount;
   int beforeTrail = g_TrailCount;
   CleanupTracking();
   if(beforeTP != g_TPCount || beforeTrail != g_TrailCount) tmStateChanged = true;

   if(tmStateChanged) SaveTMState();
}

//+------------------------------------------------------------------+
bool SyncAll()
{
   if(!SyncAccount()) return false;
   // v4.31: at a 1 s cadence a cycle sends the heartbeat and the positions;
   // the rest runs at most every FULL_SYNC_MIN_MS (a trade event and Sync now
   // send it themselves). Each extra post is a blocking WebRequest, and four
   // a second would starve the command poll.
   ulong nowMs = GetTickCount64();
   if(g_LastFullSyncMs > 0 && (nowMs - g_LastFullSyncMs) < FULL_SYNC_MIN_MS)
   {
      SyncTrades();
      return true;
   }
   g_LastFullSyncMs = nowMs;
   // Ruling g: capital flows run first among the trade/flow steps (right
   // after the account heartbeat, which must resolve g_AccountID first).
   SyncCapitalFlows();
   // v4.32: then every other balance deal, so a balance move is explained
   // by the time the server next checks it.
   SyncBalanceDeals();
   // First launch per account: backfill full deal history as daily snapshots
   if(!GlobalVariableCheck(g_HistKey) && StringLen(g_AccountID) > 0)
      SyncHistoricalSnapshots();
   // One-time correction resend (B3, ruling d/f): re-sends every already-
   // closed position with its now-correct whole-position totals, once.
   if(!GlobalVariableCheck(g_WholePosKey) && StringLen(g_AccountID) > 0)
      ResendWholePositions();
   SyncTrades();
   // Pass 2 Sol 4: sync broker-side pending orders on every Nth cycle.
   // PENDING_SYNC_EVERY=1 means every cycle (adjustable at the top of globals).
   g_PendingSyncCycle++;
   if(g_PendingSyncCycle >= PENDING_SYNC_EVERY)
   {
      g_PendingSyncCycle = 0;
      SyncPendingOrders();
   }
   return true;
}

//+------------------------------------------------------------------+
bool SyncAccount()
{
   double marginLevel = AccountInfoDouble(ACCOUNT_MARGIN_LEVEL);
   if(marginLevel < 0) marginLevel = 0;
   // Pass 1 Sol 5: account-level margin snapshot for server-side pre-check.
   // ACCOUNT_MARGIN  = margin in use across all open positions.
   // ACCOUNT_MARGIN_FREE = equity - margin_used; capital available for new trades.
   // Sentinel value of 0/0 means "EA hasn't synced these fields yet" on the
   // server; the marginCheck helper skips the gate until non-zero values arrive.
   double marginUsed = AccountInfoDouble(ACCOUNT_MARGIN);
   double marginFree = AccountInfoDouble(ACCOUNT_MARGIN_FREE);

   // Capability report (v4.24): hardcoded literals for what this EA build
   // can do, read once per heartbeat, never probed at runtime. An older
   // monitor drops unknown keys (non-strict schema); a newer monitor reads
   // an absent key as "no" (H4 — never a silent yes).
   long   tradeModeRaw = AccountInfoInteger(ACCOUNT_TRADE_MODE);
   string tradeMode    = (tradeModeRaw == ACCOUNT_TRADE_MODE_REAL)    ? "real"    :
                          (tradeModeRaw == ACCOUNT_TRADE_MODE_CONTEST) ? "contest" : "demo";

   // Broker symbol catalog hash. Railway compares it against its stored
   // truth for the (broker_company, server_name) profile and queues a
   // dump_symbols command via trade_commands when they differ.
   // Lazy-initialize g_SymbolsHash on first sync.
   if(StringLen(g_SymbolsHash) == 0) g_SymbolsHash = BuildSymbolsHash();

   // v4.25 — history_from: the earliest deal this terminal's history
   // holds, so the server knows how far back a reconcile/resend can
   // actually reach. Stays 0 (HeartbeatJson omits the key) when the
   // terminal has no deal history at all.
   // v4.28: rescanned only when the deal count moves — a full history
   // walk on every 5 s heartbeat would slow the EA's own cadence.
   HistorySelect(0, TimeCurrent());
   int histTotal = HistoryDealsTotal();
   if(histTotal != g_HistFromDeals)
   {
      datetime scanned = 0;
      for(int hd = 0; hd < histTotal; hd++)
      {
         ulong deal = HistoryDealGetTicket(hd);
         if(deal == 0) continue;
         datetime dealTime = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
         if(scanned == 0 || dealTime < scanned) scanned = dealTime;
      }
      g_HistFrom      = scanned;
      g_HistFromDeals = histTotal;
   }
   datetime historyFrom = g_HistFrom;

   // v4.32: a terminal not connected to its broker, or one that has not
   // loaded the account yet (after a restart, or a lost connection), reads
   // balance and equity as 0. That is not a reading: the heartbeat says the
   // robot is waiting, so the server never weighs it, and the rest of this
   // cycle waits too. The server answers 200, so nothing backs off.
   string waiting = AccountWaitingReason(histTotal);
   NoteWaitingChange(waiting);

   Heartbeat hb;
   hb.account_number           = AccountInfoInteger(ACCOUNT_LOGIN);
   hb.broker                   = EscapeJson(AccountInfoString(ACCOUNT_COMPANY));
   hb.balance                  = AccountInfoDouble(ACCOUNT_BALANCE);
   hb.equity                   = AccountInfoDouble(ACCOUNT_EQUITY);
   hb.floating_pnl             = AccountInfoDouble(ACCOUNT_PROFIT);
   hb.margin_level             = marginLevel;
   hb.margin_used              = marginUsed;
   hb.free_margin              = marginFree;
   hb.currency                 = AccountInfoString(ACCOUNT_CURRENCY);
   hb.leverage                 = AccountInfoInteger(ACCOUNT_LEVERAGE);
   // is_prop_firm / prop_firm_data removed — server reads Guardian rules
   // (per-user prop-firm config) which is the actual source of truth.
   hb.ea_version                = EA_VERSION;
   hb.symbols_hash              = g_SymbolsHash;
   hb.symbols_count             = SymbolsTotal(false);
   hb.server_name               = EscapeJson(AccountInfoString(ACCOUNT_SERVER));
   // v4.25: whole-position aggregation (v4.24, A04-C4) is live.
   hb.capability_partial_whole  = true;
   hb.magic_filter              = MagicFilter;
   hb.trade_mode                = tradeMode;
   hb.server_tz_offset_minutes  = (int)((TimeCurrent() - TimeGMT()) / 60);
   hb.history_from              = (long)historyFrom;
   hb.margin_level_known        = marginUsed > 0;
   hb.margin_so_call            = AccountInfoDouble(ACCOUNT_MARGIN_SO_CALL);
   hb.margin_so_so              = AccountInfoDouble(ACCOUNT_MARGIN_SO_SO);
   hb.margin_so_mode            = (AccountInfoInteger(ACCOUNT_MARGIN_SO_MODE) == ACCOUNT_STOPOUT_MODE_MONEY)
                                  ? "money" : "percent";

   string json = HeartbeatJson(hb);
   // v4.28: one id per heartbeat, reused only if this same request were
   // sent again; the monitor's replay cache keys on it. Spliced here, not
   // in HeartbeatJson, so its golden output is unchanged.
   g_HeartbeatSeq++;
   json = StringSubstr(json, 0, StringLen(json) - 1)
        + ",\"request_id\":\"" + IntegerToString(hb.account_number) + "-"
        + IntegerToString((long)GetTickCount64()) + "-" + IntegerToString((long)g_HeartbeatSeq) + "\"}";
   // v4.32: spliced the same way, only while waiting.
   if(StringLen(waiting) > 0)
      json = StringSubstr(json, 0, StringLen(json) - 1)
           + ",\"terminal_waiting\":\"" + waiting + "\"}";
   // v4.33: the watched symbols' quotes, spliced the same way, only while
   // the server asks for any.
   string quotes = QuotesJson();
   if(StringLen(quotes) > 0)
      json = StringSubstr(json, 0, StringLen(json) - 1) + ",\"quotes\":" + quotes + "}";

   string response = "";
   if(!Post("/sync/account", json, response))
   {
      // v4.28: a cadence 429 says exactly when to come back (a limiter 429
      // is handled inside Post). Older monitors send interval_seconds.
      if(g_LastHttpCode == 429 && ExtractJsonString(response, "code") != "sync_rate_limited")
      {
         double retryMs = ExtractJsonDouble(response, "retry_after_ms");
         if(retryMs < 250)    retryMs = 250;
         if(retryMs > 300000) retryMs = 300000;
         g_SyncHoldUntilMs = GetTickCount64() + (ulong)retryMs;
         AdoptSyncInterval(response);
      }
      return false;
   }
   AdoptSyncInterval(response);
   ReadWatchSymbols(response);

   // Extract account_id: {"ok":true,"account_id":"uuid"}
   int pos = StringFind(response, "\"account_id\":\"");
   if(pos >= 0)
   {
      pos += 14;
      int end = StringFind(response, "\"", pos);
      if(end > pos) g_AccountID = StringSubstr(response, pos, end - pos);
   }
   // v4.32: the account_id still arrives, so commands keep being polled,
   // but nothing else is sent until the account is loaded.
   if(StringLen(waiting) > 0) return false;
   return true;
}

//+------------------------------------------------------------------+
// v4.32: why the robot should wait before sending the account's figures:
// "not_connected" while MetaTrader has no connection to the broker,
// "not_loaded" while no account is logged in, or the account reads balance
// 0 and equity 0 with no open position and no deal in its whole history (a
// terminal that has not loaded it yet). "" when the figures are a reading.
// `histDeals` is the deal count SyncAccount just read.
string AccountWaitingReason(const int histDeals)
{
   if(TerminalInfoInteger(TERMINAL_CONNECTED) == 0) return "not_connected";
   if(AccountInfoInteger(ACCOUNT_LOGIN) == 0) return "not_loaded";
   if(AccountInfoDouble(ACCOUNT_BALANCE) == 0.0 && AccountInfoDouble(ACCOUNT_EQUITY) == 0.0
      && PositionsTotal() == 0 && histDeals == 0)
      return "not_loaded";
   return "";
}

// v4.32: one line in the Experts log each time the waiting state changes.
void NoteWaitingChange(const string waiting)
{
   if(waiting == g_WaitingReason) return;
   if(waiting == "not_connected")
      Print("Oathra: MetaTrader is not connected to the broker. Waiting before sending the account's figures.");
   else if(waiting == "not_loaded")
      Print("Oathra: MetaTrader has not loaded account ", AccountInfoInteger(ACCOUNT_LOGIN),
            " yet. Waiting before sending its figures.");
   else
      Print("Oathra: account ", AccountInfoInteger(ACCOUNT_LOGIN), " loaded. Sending its figures again.");
   g_WaitingReason = waiting;
}

//+------------------------------------------------------------------+
// v4.33 (Smart Alerts, the wiring pass 2026-10-28): the symbols the server
// asks quotes for, from its heartbeat answer's "watch_symbols" (the symbols
// the account's price alerts watch, at most 20). An answer without the key
// (an older monitor, or one that could not read the alerts) keeps the list;
// an empty array clears it. A restart starts with none.
void ReadWatchSymbols(const string response)
{
   int kpos = FindKey(response, "watch_symbols");
   if(kpos < 0) return;
   int open  = StringFind(response, "[", kpos);
   int close = StringFind(response, "]", kpos);
   if(open < 0 || close < open) return;
   string found[];
   int n = 0;
   int i = open + 1;
   while(i < close && n < MAX_WATCH_SYMBOLS)
   {
      int q1 = StringFind(response, "\"", i);
      if(q1 < 0 || q1 > close) break;
      int q2 = StringFind(response, "\"", q1 + 1);
      if(q2 < 0 || q2 > close) break;
      string sym = StringSubstr(response, q1 + 1, q2 - q1 - 1);
      if(StringLen(sym) > 0 && StringLen(sym) <= 32)
      {
         ArrayResize(found, n + 1);
         found[n] = sym;
         n++;
      }
      i = q2 + 1;
   }
   ArrayResize(g_WatchSymbols, n);
   for(int k = 0; k < n; k++) g_WatchSymbols[k] = found[k];
}

// v4.33: one quote per watched symbol, as the heartbeat's "quotes" array; ""
// when nothing is watched. The price a price alert reads is the bid. A symbol
// the terminal does not have says unknown_symbol; one with no tick yet (a shut
// market after a restart) says no_quote. age_seconds is the tick's age on the
// trade server's own clock, so the server needs no time zone to tell a stale
// quote.
string QuotesJson()
{
   int n = ArraySize(g_WatchSymbols);
   if(n == 0) return "";
   string json = "[";
   for(int k = 0; k < n; k++)
   {
      string sym = g_WatchSymbols[k];
      if(k > 0) json += ",";
      json += "{\"symbol\":\"" + EscapeJson(sym) + "\"";
      if(!SymbolSelect(sym, true))
      {
         json += ",\"error\":\"unknown_symbol\"}";
         continue;
      }
      MqlTick tick;
      if(!SymbolInfoTick(sym, tick) || tick.bid <= 0 || tick.ask <= 0)
      {
         json += ",\"error\":\"no_quote\"}";
         continue;
      }
      int  digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
      long age    = (long)(TimeCurrent() - tick.time);
      if(age < 0) age = 0;
      long spread = 0;
      bool spreadKnown = SymbolInfoInteger(sym, SYMBOL_SPREAD, spread);
      json += ",\"bid\":"           + DoubleToString(tick.bid, digits)
            + ",\"ask\":"           + DoubleToString(tick.ask, digits)
            + ",\"time\":"          + IntegerToString((long)tick.time)
            + ",\"age_seconds\":"   + IntegerToString(age)
            + ",\"spread_points\":" + (spreadKnown ? IntegerToString(spread) : "null")
            + ",\"point\":"         + DoubleToString(SymbolInfoDouble(sym, SYMBOL_POINT), digits)
            + ",\"digits\":"        + IntegerToString(digits) + "}";
   }
   json += "]";
   return json;
}

//+------------------------------------------------------------------+
// v4.28: the cadence is the server's. sync_interval_seconds (or an older
// monitor's interval_seconds on its 429) is clamped to [5, 300]; an answer
// that names neither keeps the current value.
// v4.31: sync_open_seconds and sync_flat_seconds, clamped to [1, 300], set
// the two speeds; an answer that names the interval but not them (an older
// monitor, or live sync switched off) uses the interval for both.
void AdoptSyncInterval(const string response)
{
   string key = HasField(response, "sync_interval_seconds") ? "sync_interval_seconds"
              : (HasField(response, "interval_seconds") ? "interval_seconds" : "");
   if(StringLen(key) == 0) return;
   int sec = (int)ExtractJsonDouble(response, key);
   if(sec < MIN_SYNC_SECONDS) sec = MIN_SYNC_SECONDS;
   if(sec > MAX_SYNC_SECONDS) sec = MAX_SYNC_SECONDS;
   if(sec != g_SyncSec)
   {
      Print("Oathra: sync every ", sec, "s (set by Oathra)");
      g_SyncSec = sec;
   }
   int openSec = ReadSyncSpeed(response, "sync_open_seconds", g_SyncSec);
   int flatSec = ReadSyncSpeed(response, "sync_flat_seconds", g_SyncSec);
   if(openSec != g_SyncOpenSec || flatSec != g_SyncFlatSec)
   {
      Print("Oathra: sync every ", openSec, "s with a position open, ", flatSec, "s flat (set by Oathra)");
      g_SyncOpenSec = openSec;
      g_SyncFlatSec = flatSec;
   }
}

// v4.31: one of the two speeds, clamped to [1, 300], or `fallback` when the answer does not name it.
int ReadSyncSpeed(const string response, const string key, const int fallback)
{
   if(!HasField(response, key)) return fallback;
   int sec = (int)ExtractJsonDouble(response, key);
   if(sec < MIN_FAST_SYNC_SECONDS) sec = MIN_FAST_SYNC_SECONDS;
   if(sec > MAX_SYNC_SECONDS) sec = MAX_SYNC_SECONDS;
   return sec;
}

//+------------------------------------------------------------------+
// Ruling g — deposits/withdrawals. Sends only DEAL_TYPE_BALANCE deals
// (credit, bonus, charge and commission-type deals are never flows). A
// composite (time, deal-ticket) cursor mirrors the closed-trades one; the
// very first run also sends flows_reported_from (the oldest balance deal's
// time in the WHOLE history) so the server knows how far back this
// account's flows are actually known. A non-2xx response (including 404
// from a server that predates this endpoint) holds the cursor — Post()
// already returns false for any non-2xx, so "hold on failure" falls out of
// only advancing the cursor inside the success branch below.
void SyncCapitalFlows()
{
   if(StringLen(g_AccountID) == 0) return;

   bool     firstRun   = !GlobalVariableCheck(g_FlowsCursorKey);
   datetime cursorTime = firstRun ? 0 : (datetime)(long)GlobalVariableGet(g_FlowsCursorKey);
   ulong    cursorDeal = firstRun ? 0 : (ulong)(long)GlobalVariableGet(g_FlowsDealKey);

   HistorySelect(firstRun ? 0 : cursorTime, TimeCurrent() + 60);
   int total = HistoryDealsTotal();

   Flow     flows[];
   int      flowCount  = 0;
   datetime oldestTime = 0;
   datetime newestTime = cursorTime;
   ulong    newestDeal = cursorDeal;

   for(int i = 0; i < total; i++)
   {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0) continue;

      // Only a true balance operation is a flow — credit, bonus, charge
      // and commission-type deals never reach here (ruling g).
      ENUM_DEAL_TYPE dtype = (ENUM_DEAL_TYPE)HistoryDealGetInteger(deal, DEAL_TYPE);
      if(dtype != DEAL_TYPE_BALANCE) continue;

      datetime dealTime = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
      if(!firstRun)
      {
         if(dealTime < cursorTime) continue;
         if(dealTime == cursorTime && deal <= cursorDeal) continue;
      }

      if(oldestTime == 0 || dealTime < oldestTime) oldestTime = dealTime;

      double amount = HistoryDealGetDouble(deal, DEAL_PROFIT); // balance deals carry the amount here
      ArrayResize(flows, flowCount + 1);
      flows[flowCount].ticket = (long)deal;
      flows[flowCount].kind   = (amount >= 0.0) ? "deposit" : "withdrawal";
      flows[flowCount].amount = MathAbs(amount);
      flows[flowCount].time   = dealTime;
      flowCount++;

      if(dealTime > newestTime || (dealTime == newestTime && deal > newestDeal))
      {
         newestTime = dealTime;
         newestDeal = deal;
      }
   }

   if(flowCount == 0)
   {
      if(firstRun)
      {
         // Nothing to backfill — mark the cursor at "now" so later runs
         // only look forward (mirrors SyncHistoricalSnapshots' own
         // empty-history short-circuit).
         GlobalVariableSet(g_FlowsCursorKey, (double)TimeCurrent());
         GlobalVariableSet(g_FlowsDealKey,   0.0);
      }
      return;
   }

   // CapitalFlowsJson returns {"flows":[...]} — splice its own field in
   // rather than re-wrap it (its outer braces are stripped, same pattern as
   // PositionAggJson/OpenPositionJson in BuildClosedJSON/BuildOpenJSON).
   string flowsJson  = CapitalFlowsJson(flows);
   string flowsInner = StringSubstr(flowsJson, 1, StringLen(flowsJson) - 2);

   string body = "{\"account_id\":\"" + g_AccountID + "\"," + flowsInner;
   if(firstRun) body += ",\"flows_reported_from\":" + IntegerToString(oldestTime);
   body += "}";

   string response = "";
   if(Post("/sync/capital-flows", body, response))
   {
      GlobalVariableSet(g_FlowsCursorKey, (double)newestTime);
      GlobalVariableSet(g_FlowsDealKey,   (double)newestDeal);
   }
   else if(DebugLog)
   {
      Print("Oathra: capital-flows upload failed — cursor held; will retry next sync");
   }
}

// v4.32: a balance deal that is not a trade, a deposit or a withdrawal.
// v4.33: declared here, before SyncBalanceDeals uses it. MQL5 needs a struct
// declared before its first use; 4.32 declared it further down and did not
// compile.
struct BalanceDeal
{
   long     ticket;
   string   kind;    // credit | charge | correction | bonus | commission | interest | dividend | tax
   double   amount;  // signed: what the deal did to the account
   datetime time;
};

//+------------------------------------------------------------------+
// v4.32 — every other balance deal: credit, charge, correction, bonus,
// commission-type, interest, dividend and tax. They move the balance (a
// credit moves only the credit line) without being a trade, a deposit or a
// withdrawal, and the server's reading check needs them to explain a
// balance move to the cent. Never sent as flows: /sync/capital-flows takes
// deposits and withdrawals only, and one deal of another kind would get its
// whole batch refused. Own composite cursor, the same rules as
// SyncCapitalFlows: the first run sends the whole history, in batches of
// BALANCE_DEALS_BATCH, and the cursor moves only past a batch the server
// took. A server without this endpoint answers 404, which pauses this
// endpoint alone (Post) and holds the cursor.
void SyncBalanceDeals()
{
   if(StringLen(g_AccountID) == 0) return;

   bool     firstRun   = !GlobalVariableCheck(g_BalDealsCursorKey);
   datetime cursorTime = firstRun ? 0 : (datetime)(long)GlobalVariableGet(g_BalDealsCursorKey);
   ulong    cursorDeal = firstRun ? 0 : (ulong)(long)GlobalVariableGet(g_BalDealsDealKey);

   HistorySelect(firstRun ? 0 : cursorTime, TimeCurrent() + 60);
   int total = HistoryDealsTotal();

   BalanceDeal deals[];
   int count = 0;
   for(int i = 0; i < total; i++)
   {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0) continue;

      string kind = BalanceDealKind((ENUM_DEAL_TYPE)HistoryDealGetInteger(deal, DEAL_TYPE));
      if(StringLen(kind) == 0) continue;

      datetime dealTime = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
      if(!firstRun)
      {
         if(dealTime < cursorTime) continue;
         if(dealTime == cursorTime && deal <= cursorDeal) continue;
      }

      // A non-trade deal carries its amount in DEAL_PROFIT; the other three
      // are 0 on one, and are added so a broker that books it there is not lost.
      double amount = HistoryDealGetDouble(deal, DEAL_PROFIT) + HistoryDealGetDouble(deal, DEAL_COMMISSION)
                    + HistoryDealGetDouble(deal, DEAL_SWAP)   + HistoryDealGetDouble(deal, DEAL_FEE);
      if(amount == 0.0) continue;

      ArrayResize(deals, count + 1);
      deals[count].ticket = (long)deal;
      deals[count].kind   = kind;
      deals[count].amount = amount;
      deals[count].time   = dealTime;
      count++;
   }

   if(count == 0)
   {
      if(firstRun)
      {
         // Nothing to backfill: later runs only look forward.
         GlobalVariableSet(g_BalDealsCursorKey, (double)TimeCurrent());
         GlobalVariableSet(g_BalDealsDealKey,   0.0);
      }
      return;
   }

   // History is in time order, so each batch the server takes moves the
   // cursor forward; a failed batch stops here and the next run resumes there.
   datetime doneTime = cursorTime;
   ulong    doneDeal = cursorDeal;
   for(int start = 0; start < count; start += BALANCE_DEALS_BATCH)
   {
      int end = MathMin(start + BALANCE_DEALS_BATCH, count);
      BalanceDeal batch[];
      ArrayResize(batch, end - start);
      for(int j = start; j < end; j++)
      {
         batch[j - start] = deals[j];
         if(deals[j].time > doneTime || (deals[j].time == doneTime && (ulong)deals[j].ticket > doneDeal))
         {
            doneTime = deals[j].time;
            doneDeal = (ulong)deals[j].ticket;
         }
      }

      string dealsJson  = BalanceDealsJson(batch);
      string dealsInner = StringSubstr(dealsJson, 1, StringLen(dealsJson) - 2);
      string body = "{\"account_id\":\"" + g_AccountID + "\"," + dealsInner + "}";

      string response = "";
      if(!Post("/sync/balance-deals", body, response))
      {
         if(DebugLog) Print("Oathra: balance-deals upload failed — cursor held; will retry next sync");
         return;
      }
      GlobalVariableSet(g_BalDealsCursorKey, (double)doneTime);
      GlobalVariableSet(g_BalDealsDealKey,   (double)doneDeal);
   }
}

// v4.32: the kind a balance deal is reported as; "" for a trade, a deposit
// or a withdrawal (SyncCapitalFlows), or a cancelled order.
string BalanceDealKind(const ENUM_DEAL_TYPE type)
{
   switch(type)
   {
      case DEAL_TYPE_CREDIT:                   return "credit";
      case DEAL_TYPE_CHARGE:                   return "charge";
      case DEAL_TYPE_CORRECTION:               return "correction";
      case DEAL_TYPE_BONUS:                    return "bonus";
      case DEAL_TYPE_COMMISSION:
      case DEAL_TYPE_COMMISSION_DAILY:
      case DEAL_TYPE_COMMISSION_MONTHLY:
      case DEAL_TYPE_COMMISSION_AGENT_DAILY:
      case DEAL_TYPE_COMMISSION_AGENT_MONTHLY: return "commission";
      case DEAL_TYPE_INTEREST:                 return "interest";
      case DEAL_DIVIDEND:
      case DEAL_DIVIDEND_FRANKED:              return "dividend";
      case DEAL_TAX:                           return "tax";
      default:                                 return "";
   }
}

// v4.32: {"deals":[...]}, an object for the same reason as
// CapitalFlowsJson. Amounts are signed and keep 8 decimals: daily interest
// on a small account can be under a cent.
string BalanceDealsJson(const BalanceDeal &d[])
{
   string arr = "[";
   int n = ArraySize(d);
   for(int i = 0; i < n; i++)
   {
      arr += (i > 0 ? "," : "");
      arr += "{";
      arr += "\"ticket\":" + IntegerToString(d[i].ticket)  + ",";
      arr += "\"kind\":\"" + d[i].kind                     + "\",";
      arr += "\"amount\":" + DoubleToString(d[i].amount, 8) + ",";
      arr += "\"time\":"   + IntegerToString((long)d[i].time);
      arr += "}";
   }
   arr += "]";
   return "{\"deals\":" + arr + "}";
}

//+------------------------------------------------------------------+
// B3/ruling d,f — one-time correction pass. Every fully closed position in
// the WHOLE history is re-sent through /sync/trades-bulk with
// "resend":true so the server treats it as a correction (saves a
// pre-image, updates only the money fields of the existing ticket)
// instead of an ordinary upsert. The marker is set ONLY after every batch
// in this pass returned 2xx; a failed batch leaves it unset, so the whole
// pass re-runs from scratch on the next start — safe because the server
// keys on position id (idempotent).
void ResendWholePositions()
{
   if(StringLen(g_AccountID) == 0) return;

   HistorySelect(0, TimeCurrent());
   int totalDeals = HistoryDealsTotal();

   ulong qualifiedPositions[];
   int   qualifiedCount = 0;

   for(int i = 0; i < totalDeals; i++)
   {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0) continue;

      ENUM_DEAL_ENTRY entry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(deal, DEAL_ENTRY);
      if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_INOUT && entry != DEAL_ENTRY_OUT_BY) continue;

      ulong posId = (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID);
      if(posId == 0) continue;
      if(PositionSelectByTicket(posId)) continue; // still open — not yet a closed position

      bool already = false;
      for(int k = 0; k < qualifiedCount; k++)
      {
         if(qualifiedPositions[k] == posId) { already = true; break; }
      }
      if(already) continue;

      ArrayResize(qualifiedPositions, qualifiedCount + 1);
      qualifiedPositions[qualifiedCount] = posId;
      qualifiedCount++;
   }

   if(qualifiedCount == 0)
   {
      GlobalVariableSet(g_WholePosKey, 1.0);
      return;
   }

   const int batchSize    = 200;
   bool      allBatchesOk = true;

   for(int batchStart = 0; batchStart < qualifiedCount; batchStart += batchSize)
   {
      int    batchEnd  = MathMin(batchStart + batchSize, qualifiedCount);
      string batchJson = "";
      bool   batchHas  = false;

      for(int j = batchStart; j < batchEnd; j++)
      {
         if(batchHas) batchJson += ",";
         batchJson += BuildClosedJSON(qualifiedPositions[j]);
         batchHas = true;
      }

      string batchBody = "{\"account_id\":\"" + g_AccountID + "\",\"resend\":true,\"trades\":[" + batchJson + "]}";
      string batchResp = "";
      if(!Post("/sync/trades-bulk", batchBody, batchResp))
      {
         allBatchesOk = false;
         if(DebugLog) Print("Oathra: whole-position resend batch failed — will retry the full pass next start");
         break; // marker stays unset — the whole pass re-runs, in full, next start
      }
      // Keep multi-TP / trailing monitored between batches — this pass can
      // run for minutes on an account with a long history (ReconcileHistory's
      // own pattern).
      MonitorTradeManager();
   }

   if(allBatchesOk)
   {
      GlobalVariableSet(g_WholePosKey, 1.0);
      Print("Oathra: whole-position resend complete — ", qualifiedCount, " position(s) corrected.");
   }
}

//+------------------------------------------------------------------+
void SyncTrades()
{
   if(StringLen(g_AccountID) == 0)
   {
      if(DebugLog) Print("Oathra: No account_id yet, skipping trade sync.");
      return;
   }

   string   tradesJson = "";
   bool     hasItems   = false;

   // 1. Open positions
   for(int i = 0; i < PositionsTotal(); i++)
   {
      ulong ticket = PositionGetTicket(i);
      if(ticket == 0) continue;
      if(MagicFilter != 0 && PositionGetInteger(POSITION_MAGIC) != MagicFilter) continue;

      if(hasItems) tradesJson += ",";
      tradesJson += BuildOpenJSON(ticket);
      hasItems = true;
   }

   // 2. Closed positions since last sync (full history on first run)
   //
   // X4/B3: a partial close followed by the closing exit can BOTH land in
   // one lookback window once the account is already fully closed by scan
   // time — the old code sent each such deal separately (each carrying
   // only its own slice of lots). Collect DISTINCT POSITION ids instead of
   // raw deal tickets, so BuildClosedJSON(posId)'s own full-history walk
   // (HistorySelectByPosition, unbounded) sums every exit of that position
   // exactly once, regardless of how many of its exit deals fall inside
   // this particular scan window.
   datetime lookback = (g_LastClosed > 0) ? g_LastClosed : 0;
   HistorySelect(lookback, TimeCurrent() + 60);

   int totalDeals = HistoryDealsTotal();
   ulong    posIds[];
   datetime posMaxTime[];   // this position's latest qualifying exit time seen in this scan
   ulong    posMaxDeal[];   // …and that exit's own deal ticket (composite cursor tie-break)
   int      closedCount = 0;

   for(int i = 0; i < totalDeals; i++)
   {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0) continue;

      // DEAL_ENTRY_OUT_BY is a "close by" hedge exit — counts as an exit
      // just like OUT/INOUT (X4; the old filter silently dropped it).
      ENUM_DEAL_ENTRY entry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(deal, DEAL_ENTRY);
      if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_INOUT && entry != DEAL_ENTRY_OUT_BY) continue;

      if(MagicFilter != 0 && HistoryDealGetInteger(deal, DEAL_MAGIC) != MagicFilter) continue;

      // Skip balance adjustments / bonuses (no position id)
      ulong posCheck = (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID);
      if(posCheck == 0) continue;

      // Partial-close detection: if the position still exists RIGHT NOW,
      // every exit deal seen so far was a partial close. Skip — the
      // eventual FINAL exit deal (when the position fully closes) makes it
      // qualify, and BuildClosedJSON then re-walks every exit deal itself.
      if(PositionSelectByTicket(posCheck)) continue;

      datetime dealTime = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
      // Composite cursor: skip deals strictly before cursor time, and
      // deals at the cursor time whose deal-ticket has already been sent.
      if(dealTime < g_LastClosed) continue;
      if(dealTime == g_LastClosed && deal <= g_LastClosedDeal) continue;

      int idx = -1;
      for(int k = 0; k < closedCount; k++)
      {
         if(posIds[k] == posCheck) { idx = k; break; }
      }
      if(idx < 0)
      {
         ArrayResize(posIds,     closedCount + 1);
         ArrayResize(posMaxTime, closedCount + 1);
         ArrayResize(posMaxDeal, closedCount + 1);
         idx = closedCount;
         posIds[idx]     = posCheck;
         posMaxTime[idx] = dealTime;
         posMaxDeal[idx] = deal;
         closedCount++;
      }
      else if(dealTime > posMaxTime[idx] || (dealTime == posMaxTime[idx] && deal > posMaxDeal[idx]))
      {
         posMaxTime[idx] = dealTime;
         posMaxDeal[idx] = deal;
      }
   }

   // Send open positions first.
   // /sync/trades runs stale-detection that assumes the payload is the FULL
   // current open list. When MagicFilter is set the loop above sent only a
   // subset, so stale-detection would mark every other-magic open trade as
   // closed — route filtered syncs to /sync/trades-bulk (no stale-detection).
   if(hasItems)
   {
      string openBody = "{\"account_id\":\"" + g_AccountID + "\",\"trades\":[" + tradesJson + "]}";
      string openResp = "";
      Post(MagicFilter != 0 ? "/sync/trades-bulk" : "/sync/trades", openBody, openResp);
   }

   // Send closed deals in batches of 200.
   // Always use /sync/trades-bulk: /sync/trades runs stale-detection that
   // assumes the payload is the FULL current open list, so a closed-only
   // batch there would mark every legitimately open trade as stale.
   const int    batchSize     = 200;
   const string bulkEndpoint  = "/sync/trades-bulk";

   int sentClosed = 0;
   for(int batchStart = 0; batchStart < closedCount; batchStart += batchSize)
   {
      string batchJson = "";
      bool batchHas = false;
      int batchEnd = MathMin(batchStart + batchSize, closedCount);

      // Per-batch high-water mark — only persisted to the cursor when
      // the POST returns success. The previous code advanced a single
      // global cursor regardless of upload outcome, so a failed batch
      // would silently skip its deals on the next sync.
      datetime batchMaxTime = g_LastClosed;
      ulong    batchMaxDeal = g_LastClosedDeal;

      for(int j = batchStart; j < batchEnd; j++)
      {
         if(batchHas) batchJson += ",";
         batchJson += BuildClosedJSON(posIds[j]);
         batchHas = true;
         if(posMaxTime[j] > batchMaxTime) {
            batchMaxTime = posMaxTime[j];
            batchMaxDeal = posMaxDeal[j];
         }
         else if(posMaxTime[j] == batchMaxTime && posMaxDeal[j] > batchMaxDeal) {
            batchMaxDeal = posMaxDeal[j];
         }
      }

      if(batchHas)
      {
         // v4.28: closes the incremental cursor found are trade events the
         // server announces (alerts); the first-launch backfill
         // (lookback 0) is history and is not.
         string batchBody = "{\"account_id\":\"" + g_AccountID + "\","
                          + (lookback > 0 ? "\"announce\":true," : "")
                          + "\"trades\":[" + batchJson + "]}";
         string batchResp = "";
         if(Post(bulkEndpoint, batchBody, batchResp))
         {
            // Advance cursor only on confirmed upload. If batchMaxTime
            // hasn't moved past the cursor (small batch with stale data),
            // the comparison naturally no-ops.
            if(batchMaxTime > g_LastClosed
               || (batchMaxTime == g_LastClosed && batchMaxDeal > g_LastClosedDeal))
            {
               g_LastClosed     = batchMaxTime;
               g_LastClosedDeal = batchMaxDeal;
               GlobalVariableSet(g_AccountKey,  (double)g_LastClosed);
               GlobalVariableSet(g_LastDealKey, (double)g_LastClosedDeal);
            }
            sentClosed += (batchEnd - batchStart);
            Print("Oathra: Sent batch ", (batchStart / batchSize) + 1,
                  " (", batchEnd - batchStart, " trades)");
         }
         else if(DebugLog)
         {
            Print("Oathra: Batch ", (batchStart / batchSize) + 1,
                  " upload failed — cursor held; will retry next sync");
         }
      }
   }

   if(closedCount == 0 && !hasItems)
   {
      if(DebugLog) Print("Oathra: No trades to sync.");
   }

   if(sentClosed > 0)
      Print("Oathra: SyncTrades complete — ", sentClosed, " of ", closedCount,
            " closed deals confirmed in batches of ", batchSize);
}

//+------------------------------------------------------------------+
// Pass 2 Sol 4: broker-side pending orders sync.
// Builds a JSON array of all current pending orders (OrdersTotal loop)
// and posts to /sync/pending_orders. The monitor upserts into
// broker_pending_orders and the webapp tab switches to that source.
//
// Cadence: called from SyncAll every PENDING_SYNC_EVERY cycles (see
// g_PendingSyncCycle counter) rather than every cycle, to bound the
// cost for users with many pending orders. Skips entirely when
// OrdersTotal() == 0 (zero overhead for accounts without pending orders).
//+------------------------------------------------------------------+
void SyncPendingOrders()
{
   if(StringLen(g_AccountID) == 0) return;

   int total = OrdersTotal();
   // Fast exit: no pending orders — send an empty array to let the server
   // know the book is clear (removes stale rows from the last sync).
   string json = "[";
   bool hasItems = false;

   for(int i = 0; i < total; i++)
   {
      ulong ticket = OrderGetTicket(i);
      if(ticket == 0) continue;
      // Respect MagicFilter on pending side as well (0 = sync all)
      if(MagicFilter != 0 && OrderGetInteger(ORDER_MAGIC) != MagicFilter) continue;

      if(hasItems) json += ",";
      json += "{";
      json += "\"ticket\":"     + IntegerToString((long)ticket)                              + ",";
      json += "\"order_type\":\"" + EscapeJson(OrderTypeToString((ENUM_ORDER_TYPE)OrderGetInteger(ORDER_TYPE))) + "\",";
      json += "\"symbol\":\""  + EscapeJson(OrderGetString(ORDER_SYMBOL))                  + "\",";
      json += "\"lots\":"      + DoubleToString(OrderGetDouble(ORDER_VOLUME_CURRENT), 2)   + ",";
      json += "\"price\":"     + DoubleToString(OrderGetDouble(ORDER_PRICE_OPEN),    5)    + ",";
      json += "\"stop_loss\":" + DoubleToString(OrderGetDouble(ORDER_SL),            5)    + ",";
      json += "\"take_profit\":" + DoubleToString(OrderGetDouble(ORDER_TP),          5)    + ",";
      json += "\"magic\":"     + IntegerToString(OrderGetInteger(ORDER_MAGIC))             + ",";
      json += "\"comment\":\"" + EscapeJson(OrderGetString(ORDER_COMMENT))                 + "\",";
      // Expiration: ORDER_TIME_EXPIRATION is broker-server time. The monitor
      // stores expiration as UTC epoch seconds, so convert by subtracting the
      // server offset (server time − offset = UTC). 0 = GTC; left as 0.
      long expiration = (long)OrderGetInteger(ORDER_TIME_EXPIRATION);
      if(expiration > 0)
         expiration -= (long)(TimeCurrent() - TimeGMT());
      json += "\"expiration\":" + IntegerToString(expiration);
      json += "}";
      hasItems = true;
   }
   json += "]";

   // Route through Post() so this call gets the same auth header, exponential
   // backoff and 401/423 surfacing as every other sync endpoint. account_id
   // travels in the query string; the body is {"orders":[...]}.
   string body     = "{\"orders\":" + json + "}";
   string response = "";
   if(!Post("/sync/pending_orders?account_id=" + g_AccountID, body, response))
   {
      if(DebugLog) Print("Oathra: SyncPendingOrders failed — see HTTP log above");
   }
   else if(DebugLog)
   {
      Print("Oathra: SyncPendingOrders ok — ", total, " pending orders synced");
   }
}

//+------------------------------------------------------------------+
// Helper: convert MQL5 ORDER_TYPE enum to the string the server expects.
// The server uses the same keys as open_pending's order_type field.
//+------------------------------------------------------------------+
string OrderTypeToString(ENUM_ORDER_TYPE t)
{
   switch(t)
   {
      case ORDER_TYPE_BUY_LIMIT:       return "buy_limit";
      case ORDER_TYPE_SELL_LIMIT:      return "sell_limit";
      case ORDER_TYPE_BUY_STOP:        return "buy_stop";
      case ORDER_TYPE_SELL_STOP:       return "sell_stop";
      case ORDER_TYPE_BUY_STOP_LIMIT:  return "buy_stop_limit";
      case ORDER_TYPE_SELL_STOP_LIMIT: return "sell_stop_limit";
      default:                          return "unknown";
   }
}

//+------------------------------------------------------------------+
// A04-C4 — whole-position serialization (B3, B4, B5; ruling a, g, h).
//
// These three structs and the pure functions that serialize them are the
// EXACT contract A04-C51's SerializerGolden.mq5 was written against
// (tools/mt5/tests/SerializerGolden.mq5's own header comment): same field
// names, same types. A drift here is a compile error in that golden
// script, not a silent mismatch — keep both copies in sync by hand.
struct PositionAgg
{
   double   lots;             // symbol volume-step precision, never a fixed 2 (ruling a)
   double   pnl;
   double   swap;
   double   commission;
   double   close_price;
   datetime close_time;
   int      exit_deal_count;
   bool     costs_reported;
   long     magic_number;
   int      volume_digits;
};

struct OpenPos
{
   double   lots;
   double   open_price;
   datetime open_time;
   double   commission;
   double   swap;
   double   tick_value;
   double   tick_size;
   bool     costs_reported;
   long     magic_number;
   int      volume_digits;
};

struct Flow
{
   long     ticket;
   string   kind;    // "deposit" | "withdrawal" — DEAL_TYPE_BALANCE sign only (ruling g)
   double   amount;
   datetime time;
};

// A04-C8 — the heartbeat, same "own bytes" contract as PositionAgg/OpenPos/
// Flow above: this is the EXACT shape A04-C51's HeartbeatGolden.mq5 was
// written against (that file's own header comment). broker/server_name
// arrive already EscapeJson()-escaped — HeartbeatJson() below must stay
// pure, never touching AccountInfo*/broker state itself. history_from is 0
// when SyncAccount() found no deals at all; HeartbeatJson() omits the key
// rather than sending a literal 0 (L6: missing data is "not reported").
// A drift here is a compile error in the golden script, not a silent
// mismatch — keep both copies in sync by hand.
struct Heartbeat
{
   long     account_number;
   string   broker;
   double   balance;
   double   equity;
   double   floating_pnl;
   double   margin_level;
   double   margin_used;
   double   free_margin;
   string   currency;
   long     leverage;
   string   ea_version;
   string   symbols_hash;
   int      symbols_count;
   string   server_name;
   bool     capability_partial_whole;
   int      magic_filter;
   string   trade_mode;
   int      server_tz_offset_minutes;
   long     history_from;
   // v4.26 (A07-C15, B19): the broker's stop-out ladder as the terminal
   // reports it, and whether margin_level means anything this beat — it
   // does not while no margin is in use (D10: sent as null, never 0).
   bool     margin_level_known;
   double   margin_so_call;
   double   margin_so_so;
   string   margin_so_mode;             // "percent" or "money"
};

// Number of decimal places the symbol's own volume step needs (ruling a) —
// 0.01 → 2, 0.001 → 3, 0.0001 → 4, capped at 8 (L9: lots are stored at up
// to 8 decimals). Broker-dependent, so this is deliberately NOT part of
// any @export region — the pure JSON builders below only ever receive an
// already-resolved digit count, never call SymbolInfo*() themselves.
int VolumeDigits(const string sym)
{
   double step = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
   if(step <= 0.0) return 2; // no step reported — today's fixed precision, never invented finer
   int digits = 0;
   double scaled = step;
   while(digits < 8 && MathAbs(scaled - MathRound(scaled)) > 0.0000001)
   {
      scaled *= 10.0;
      digits++;
   }
   return digits;
}

// Sums commission + fee over an OPEN position's entry deal(s) only (it has
// no exit deals yet) — B4/L6: an open position's commission is never a
// literal 0 standing in for "not summed", it is the entry cost actually
// charged.
double OpenPositionEntryCommission(ulong positionId)
{
   double total = 0.0;
   if(!HistorySelectByPosition(positionId)) return total;
   int total_deals = HistoryDealsTotal();
   for(int d = 0; d < total_deals; d++)
   {
      ulong dTicket = HistoryDealGetTicket(d);
      if(dTicket == 0) continue;
      ENUM_DEAL_ENTRY e = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(dTicket, DEAL_ENTRY);
      if(e == DEAL_ENTRY_IN || e == DEAL_ENTRY_INOUT)
         total += HistoryDealGetDouble(dTicket, DEAL_COMMISSION) + HistoryDealGetDouble(dTicket, DEAL_FEE);
   }
   return total;
}

// @export-begin PositionAggJson
// Pure: every value comes from the struct, never from a broker/history
// lookup — this is what C51's harness compiles standalone into
// SerializerGolden.mq5 to prove the EA's own bytes produce this shape.
string PositionAggJson(const PositionAgg &p)
{
   string json = "{";
   json += "\"lots\":"            + DoubleToString(p.lots, p.volume_digits) + ",";
   json += "\"pnl\":"             + DoubleToString(p.pnl, 2)                + ",";
   json += "\"swap\":"            + DoubleToString(p.swap, 2)               + ",";
   json += "\"commission\":"      + DoubleToString(p.commission, 2)         + ",";
   json += "\"close_price\":"     + DoubleToString(p.close_price, 5)        + ",";
   json += "\"close_time\":"      + IntegerToString((long)p.close_time)     + ",";
   json += "\"exit_deal_count\":" + IntegerToString(p.exit_deal_count)      + ",";
   json += "\"costs_reported\":"  + (p.costs_reported ? "true" : "false")   + ",";
   json += "\"magic_number\":"    + IntegerToString(p.magic_number);
   json += "}";
   return json;
}
// @export-end

// @export-begin OpenPositionJson
string OpenPositionJson(const OpenPos &p)
{
   string json = "{";
   json += "\"lots\":"           + DoubleToString(p.lots, p.volume_digits) + ",";
   json += "\"open_price\":"     + DoubleToString(p.open_price, 5)         + ",";
   json += "\"open_time\":"      + IntegerToString((long)p.open_time)      + ",";
   json += "\"commission\":"     + DoubleToString(p.commission, 2)         + ",";
   json += "\"swap\":"           + DoubleToString(p.swap, 2)               + ",";
   json += "\"tick_value\":"     + DoubleToString(p.tick_value, 8)         + ",";
   json += "\"tick_size\":"      + DoubleToString(p.tick_size, 8)          + ",";
   json += "\"costs_reported\":" + (p.costs_reported ? "true" : "false")   + ",";
   json += "\"magic_number\":"   + IntegerToString(p.magic_number);
   json += "}";
   return json;
}
// @export-end

// @export-begin CapitalFlowsJson
// Returns a JSON OBJECT with one "flows" array (never a bare array): a
// script's raw output is merged as `{...raw, _origin}` by the test harness
// (tools/mt5/tests/run-mql-script.mjs), which only works on an object — a
// bare array would come back re-keyed by numeric index and silently
// corrupted. The caller splices this object's own field into the larger
// sync body (ruling g: deposit/withdrawal only, credit/bonus/charge/
// commission deals never reach this function).
string CapitalFlowsJson(const Flow &f[])
{
   string arr = "[";
   int n = ArraySize(f);
   for(int i = 0; i < n; i++)
   {
      // Ternary, deliberately not a conditional keyword: extract-regions.mjs's
      // function-definition scan mistakes an "else" branch of that keyword,
      // used anywhere else in this whole file, for a function of that same
      // name — a documented over-match in a shared tool this chunk does not
      // own. The leading comma is exactly as conditional either way.
      arr += (i > 0 ? "," : "");
      arr += "{";
      arr += "\"ticket\":" + IntegerToString(f[i].ticket)  + ",";
      arr += "\"kind\":\"" + f[i].kind                     + "\",";
      arr += "\"amount\":" + DoubleToString(f[i].amount, 2) + ",";
      arr += "\"time\":"   + IntegerToString((long)f[i].time);
      arr += "}";
   }
   arr += "]";
   string json = "{\"flows\":" + arr + "}";
   return json;
}
// @export-end

// @export-begin HeartbeatJson
// Pure: every value comes from the struct, never an AccountInfo*/broker
// lookup — this is what C51's harness compiles standalone into
// HeartbeatGolden.mq5 to prove the EA's own bytes produce this shape.
// history_from is only emitted when the caller found at least one deal
// (L6: missing money/history data is "not reported", never a literal 0).
// margin_level is null while no margin is in use (v4.26, D10) — the same
// rule: a flat account's margin level is not 0, it does not exist.
string HeartbeatJson(const Heartbeat &h)
{
   string json = "{";
   json += "\"account_number\":\"" + IntegerToString(h.account_number) + "\",";
   json += "\"broker\":\""         + h.broker                          + "\",";
   json += "\"platform\":\"MT5\",";
   json += "\"balance\":"          + DoubleToString(h.balance,      2) + ",";
   json += "\"equity\":"           + DoubleToString(h.equity,       2) + ",";
   json += "\"floating_pnl\":"     + DoubleToString(h.floating_pnl, 2) + ",";
   json += "\"margin_level\":"     + (h.margin_level_known ? DoubleToString(h.margin_level, 2) : "null") + ",";
   json += "\"margin_used\":"      + DoubleToString(h.margin_used,  2) + ",";
   json += "\"free_margin\":"      + DoubleToString(h.free_margin,  2) + ",";
   json += "\"currency\":\""       + h.currency                        + "\",";
   json += "\"leverage\":"         + IntegerToString(h.leverage)      + ",";
   json += "\"ea_version\":\""     + h.ea_version                     + "\",";
   json += "\"symbols_hash\":\""   + h.symbols_hash                   + "\",";
   json += "\"symbols_count\":"    + IntegerToString(h.symbols_count) + ",";
   json += "\"server_name\":\""    + h.server_name                    + "\",";
   json += "\"capabilities\":{\"version\":1,\"account_scope_close\":true,\"position_result\":true,\"partial_whole\":"
         + (h.capability_partial_whole ? "true" : "false")
         + ",\"stopout_levels\":true,\"fill_prices\":false,\"symbol_spread\":true"
         + ",\"open_safety\":true,\"close_opened_before\":true"
         + ",\"ticket_scope_close\":true,\"pending_scope_cancel\":true},";
   json += "\"magic_filter\":"     + IntegerToString(h.magic_filter)  + ",";
   json += "\"trade_mode\":\""     + h.trade_mode                     + "\",";
   json += "\"server_tz_offset_minutes\":" + IntegerToString(h.server_tz_offset_minutes);
   json += ",\"margin_so_call\":"  + DoubleToString(h.margin_so_call, 2);
   json += ",\"margin_so_so\":"    + DoubleToString(h.margin_so_so,   2);
   json += ",\"margin_so_mode\":\"" + h.margin_so_mode + "\"";
   if(h.history_from > 0)
      json += ",\"history_from\":" + IntegerToString(h.history_from);
   json += "}";
   return json;
}
// @export-end

//+------------------------------------------------------------------+
string BuildOpenJSON(ulong ticket)
{
   double   initialRiskAmount = 0.0;
   bool     hadStopLossAtOpen = false;
   datetime stopLossSetAt     = 0;
   ulong    positionId = (ulong)PositionGetInteger(POSITION_IDENTIFIER);
   string   symbol     = PositionGetString(POSITION_SYMBOL);
   string   direction  = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY ? "buy" : "sell");
   double   lots       = PositionGetDouble(POSITION_VOLUME);
   double   openPrice  = PositionGetDouble(POSITION_PRICE_OPEN);

   ResolvePositionRiskMeta(positionId, symbol, direction, lots, openPrice,
                           initialRiskAmount, hadStopLossAtOpen, stopLossSetAt);

   // B5/L6: commission comes from the entry deal(s), never a literal 0;
   // tick_value/tick_size travel with the row so the server can compute
   // notional exposure without its own (empty, B9) broker_symbols lookup.
   OpenPos agg;
   agg.lots            = lots;
   agg.open_price       = openPrice;
   agg.open_time        = (datetime)PositionGetInteger(POSITION_TIME);
   agg.commission        = OpenPositionEntryCommission(positionId);
   agg.swap              = PositionGetDouble(POSITION_SWAP);
   agg.tick_value        = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   agg.tick_size         = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   agg.costs_reported    = true;
   agg.magic_number      = (long)PositionGetInteger(POSITION_MAGIC);
   agg.volume_digits     = VolumeDigits(symbol);

   string aggJson  = OpenPositionJson(agg);
   string aggInner = StringSubstr(aggJson, 1, StringLen(aggJson) - 2);

   // ticket field uses POSITION_IDENTIFIER (the immutable origin id)
   // so open and closed records key on the same value. The previous
   // mismatch (POSITION_TICKET on open vs DEAL_POSITION_ID on closed)
   // could de-sync rows when the broker reassigned position tickets
   // after partial close / netting / hedge rollovers.
   string json = "{";
   json += "\"ticket\":"      + IntegerToString(positionId)                                          + ",";
   json += "\"symbol\":\""    + EscapeJson(symbol)                                                   + "\",";
   json += "\"direction\":\"" + direction                                                            + "\",";
   json += aggInner                                                                                  + ",";
   json += "\"close_price\":" + DoubleToString(PositionGetDouble(POSITION_PRICE_CURRENT),       5)  + ",";
   json += "\"stop_loss\":"   + DoubleToString(PositionGetDouble(POSITION_SL),                  5)  + ",";
   json += "\"take_profit\":" + DoubleToString(PositionGetDouble(POSITION_TP),                  5)  + ",";
   json += "\"pnl\":"         + DoubleToString(PositionGetDouble(POSITION_PROFIT),              2)  + ",";
   json += "\"close_time\":null,";
   json += "\"comment\":\""   + EscapeJson(PositionGetString(POSITION_COMMENT))                     + "\",";
   json += "\"initial_risk_amount\":" + DoubleToString(initialRiskAmount, 2)                        + ",";
   json += "\"initial_risk_pct\":null,";
   json += "\"had_stop_loss_at_open\":" + (hadStopLossAtOpen ? "true" : "false")                    + ",";
   json += "\"stop_loss_set_at\":" + (stopLossSetAt > 0 ? IntegerToString(stopLossSetAt) : "null")  + ",";
   // v4.33: the symbol's spread now, its point and its digits (a Smart Alert
   // on the spread reads them); null when the terminal does not report it.
   long spreadPoints = 0;
   bool spreadKnown  = SymbolInfoInteger(symbol, SYMBOL_SPREAD, spreadPoints);
   int  symDigits    = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   json += "\"spread_points\":" + (spreadKnown ? IntegerToString(spreadPoints) : "null") + ",";
   json += "\"point\":"         + DoubleToString(SymbolInfoDouble(symbol, SYMBOL_POINT), symDigits) + ",";
   json += "\"digits\":"        + IntegerToString(symDigits) + ",";
   json += "\"status\":\"open\"";
   json += "}";
   return json;
}

//+------------------------------------------------------------------+
// B3/X4 — whole position, not one exit deal. Takes the POSITION id (not a
// deal ticket, unlike the old per-deal version) and walks every deal of
// that position via HistorySelectByPosition: lots/pnl/swap are summed over
// every exit deal (DEAL_ENTRY_OUT, DEAL_ENTRY_INOUT and DEAL_ENTRY_OUT_BY —
// a "close by" hedge exit — all count, X4), commission+fee are summed over
// EVERY deal of the position (B4), close_price is the volume-weighted exit
// price, close_time is the last exit's time, and magic_number/open_price/
// open_time come from the first entry deal found (today's convention).
// Caller must have already confirmed the position is no longer open — this
// function only reads history, it never checks PositionSelectByTicket.
string BuildClosedJSON(ulong posId)
{
   double         openPrice  = 0;
   datetime       openTime   = 0;
   double         stopLoss   = 0;
   double         takeProfit = 0;
   double         initialRiskAmount = 0.0;
   bool           hadStopLossAtOpen = false;
   datetime       stopLossSetAt     = 0;
   string         symbol      = "";
   string         comment     = "";
   long           magicNumber = 0;
   ENUM_DEAL_TYPE lastExitType = DEAL_TYPE_BUY;

   double   sumCommission = 0.0; // DEAL_COMMISSION + DEAL_FEE, every deal (B4)
   double   sumExitLots   = 0.0;
   double   sumExitPnl    = 0.0;
   double   sumExitSwap   = 0.0;
   double   weightedPrice = 0.0; // Σ price × volume, exits only
   datetime lastCloseTime = 0;
   int      exitDealCount = 0;
   bool     foundEntry    = false;

   // HistorySelectByPosition replaces the global selection — caller must not
   // be iterating HistoryDealsTotal() at the same time.
   if(HistorySelectByPosition(posId))
   {
      int total = HistoryDealsTotal();
      for(int d = 0; d < total; d++)
      {
         ulong dTicket = HistoryDealGetTicket(d);
         if(dTicket == 0) continue;

         sumCommission += HistoryDealGetDouble(dTicket, DEAL_COMMISSION) + HistoryDealGetDouble(dTicket, DEAL_FEE);

         ENUM_DEAL_ENTRY e = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(dTicket, DEAL_ENTRY);
         if(!foundEntry && (e == DEAL_ENTRY_IN || e == DEAL_ENTRY_INOUT))
         {
            openPrice    = HistoryDealGetDouble(dTicket, DEAL_PRICE);
            openTime     = (datetime)HistoryDealGetInteger(dTicket, DEAL_TIME);
            symbol       = HistoryDealGetString(dTicket, DEAL_SYMBOL);
            magicNumber  = (long)HistoryDealGetInteger(dTicket, DEAL_MAGIC);
            foundEntry   = true;
         }
         else if(e == DEAL_ENTRY_OUT || e == DEAL_ENTRY_INOUT || e == DEAL_ENTRY_OUT_BY)
         {
            double   vol   = HistoryDealGetDouble(dTicket, DEAL_VOLUME);
            double   price = HistoryDealGetDouble(dTicket, DEAL_PRICE);
            datetime dTime = (datetime)HistoryDealGetInteger(dTicket, DEAL_TIME);

            sumExitLots   += vol;
            sumExitPnl    += HistoryDealGetDouble(dTicket, DEAL_PROFIT);
            sumExitSwap   += HistoryDealGetDouble(dTicket, DEAL_SWAP);
            weightedPrice += price * vol;
            exitDealCount++;
            if(StringLen(symbol) == 0) symbol = HistoryDealGetString(dTicket, DEAL_SYMBOL);

            if(dTime >= lastCloseTime)
            {
               lastCloseTime = dTime;
               lastExitType  = (ENUM_DEAL_TYPE)HistoryDealGetInteger(dTicket, DEAL_TYPE);
               comment       = HistoryDealGetString(dTicket, DEAL_COMMENT);
            }
         }
      }
      for(int o = 0; o < HistoryOrdersTotal(); o++)
      {
         ulong ord = HistoryOrderGetTicket(o);
         if((ulong)HistoryOrderGetInteger(ord, ORDER_POSITION_ID) == posId)
         {
            if(stopLoss == 0)   stopLoss   = HistoryOrderGetDouble(ord, ORDER_SL);
            if(takeProfit == 0) takeProfit = HistoryOrderGetDouble(ord, ORDER_TP);
         }
      }
   }

   // A SELL deal closes a BUY position and vice versa — sourced from the
   // last exit deal (today's convention, now correct even when an earlier
   // partial exit's own type would have disagreed with a later one, which
   // cannot happen for a single net position but keeps the same read).
   string direction  = (lastExitType == DEAL_TYPE_SELL) ? "buy" : "sell";
   double closePrice = (sumExitLots > 0.0) ? (weightedPrice / sumExitLots) : 0.0;

   ResolvePositionRiskMeta(posId, symbol, direction, sumExitLots, openPrice,
                           initialRiskAmount, hadStopLossAtOpen, stopLossSetAt);

   PositionAgg agg;
   agg.lots             = sumExitLots;
   agg.pnl               = sumExitPnl;
   agg.swap              = sumExitSwap;
   agg.commission         = sumCommission;
   agg.close_price        = closePrice;
   agg.close_time         = lastCloseTime;
   agg.exit_deal_count    = exitDealCount;
   agg.costs_reported     = true;
   agg.magic_number       = magicNumber;
   agg.volume_digits      = VolumeDigits(symbol);

   string aggJson  = PositionAggJson(agg);
   string aggInner = StringSubstr(aggJson, 1, StringLen(aggJson) - 2);

   string json = "{";
   json += "\"ticket\":"       + IntegerToString(posId)                                              + ",";
   json += "\"symbol\":\""     + EscapeJson(symbol)                                                  + "\",";
   json += "\"direction\":\""  + direction                                                           + "\",";
   json += aggInner                                                                                  + ",";
   json += "\"open_price\":"   + DoubleToString(openPrice,                                       5)  + ",";
   json += "\"open_time\":"    + IntegerToString(openTime)                                           + ",";
   json += "\"stop_loss\":"    + DoubleToString(stopLoss,                                        5)  + ",";
   json += "\"take_profit\":"  + DoubleToString(takeProfit,                                      5)  + ",";
   json += "\"comment\":\""    + EscapeJson(comment)                                                 + "\",";
   json += "\"initial_risk_amount\":" + DoubleToString(initialRiskAmount, 2)                         + ",";
   json += "\"initial_risk_pct\":null,";
   json += "\"had_stop_loss_at_open\":" + (hadStopLossAtOpen ? "true" : "false")                     + ",";
   json += "\"stop_loss_set_at\":" + (stopLossSetAt > 0 ? IntegerToString(stopLossSetAt) : "null")   + ",";
   json += "\"status\":\"closed\"";
   json += "}";
   return json;
}

//+------------------------------------------------------------------+
// Builds full daily-balance history from all deals on this account and
// POSTs to /sync/snapshots. Gated by g_HistKey so it never re-runs.
void SyncHistoricalSnapshots()
{
   HistorySelect(0, TimeCurrent());
   int total = HistoryDealsTotal();

   if(total == 0)
   {
      GlobalVariableSet(g_HistKey, 1.0);
      return;
   }

   // Seed the running balance from current balance minus the sum of
   // visible deal PnL. Old behavior started at 0, which made the first
   // snapshot wrong by exactly the account's pre-history capital — for
   // accounts where the broker has rolled off old history (most cap
   // 1-3 years), the equity chart's starting point was meaningless.
   double sumVisiblePnL = 0.0;
   for(int i = 0; i < total; i++)
   {
      ulong d = HistoryDealGetTicket(i);
      if(d == 0) continue;
      sumVisiblePnL += HistoryDealGetDouble(d, DEAL_PROFIT)
                    +  HistoryDealGetDouble(d, DEAL_SWAP)
                    +  HistoryDealGetDouble(d, DEAL_COMMISSION);
   }
   double currentBalance = AccountInfoDouble(ACCOUNT_BALANCE);
   double running_balance = currentBalance - sumVisiblePnL;
   string last_date       = "";
   string snapsJson       = "";
   bool   hasItems        = false;

   for(int i = 0; i < total; i++)
   {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0) continue;

      datetime dealTime = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
      MqlDateTime dt;
      TimeToStruct(dealTime, dt);
      string dealDate = StringFormat("%04d-%02d-%02d", dt.year, dt.mon, dt.day);

      if(dealDate != last_date && last_date != "")
      {
         if(hasItems) snapsJson += ",";
         snapsJson += "{\"date\":\"" + last_date + "\","
                    + "\"balance\":" + DoubleToString(running_balance, 2) + ","
                    + "\"equity\":"  + DoubleToString(running_balance, 2) + "}";
         hasItems = true;
      }

      running_balance += HistoryDealGetDouble(deal, DEAL_PROFIT)
                       + HistoryDealGetDouble(deal, DEAL_SWAP)
                       + HistoryDealGetDouble(deal, DEAL_COMMISSION);
      last_date = dealDate;
   }

   if(last_date != "")
   {
      if(hasItems) snapsJson += ",";
      snapsJson += "{\"date\":\"" + last_date + "\","
                 + "\"balance\":" + DoubleToString(running_balance, 2) + ","
                 + "\"equity\":"  + DoubleToString(running_balance, 2) + "}";
   }

   if(StringLen(snapsJson) == 0)
   {
      GlobalVariableSet(g_HistKey, 1.0);
      return;
   }

   string body     = "{\"account_id\":\"" + g_AccountID + "\",\"snapshots\":[" + snapsJson + "]}";
   string response = "";

   if(Post("/sync/snapshots", body, response))
   {
      GlobalVariableSet(g_HistKey, 1.0);
      Print("Oathra: Historical snapshots synced successfully.");
   }
   else if(DebugLog) Print("Oathra: Historical snapshot sync failed — will retry next launch.");
}

//+------------------------------------------------------------------+
// Reconcile trade history: re-upload ALL closed deals. Server upserts
// (no duplicates). stats_processed prevents double-counting.
void ReconcileHistory()
{
   if(StringLen(g_AccountID) == 0) return;

   Print("Oathra: Starting full trade history reconciliation...");

   HistorySelect(0, TimeCurrent());
   int totalDeals = HistoryDealsTotal();

   // X4/B3: dedupe by POSITION, same reason as SyncTrades — a position with
   // more than one exit deal (partial closes) would otherwise be reported
   // once per exit deal instead of once, whole.
   ulong qualifiedPositions[];
   int   qualifiedCount = 0;

   for(int i = 0; i < totalDeals; i++)
   {
      ulong deal = HistoryDealGetTicket(i);
      if(deal == 0) continue;

      ENUM_DEAL_ENTRY entry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(deal, DEAL_ENTRY);
      if(entry != DEAL_ENTRY_OUT && entry != DEAL_ENTRY_INOUT && entry != DEAL_ENTRY_OUT_BY) continue;
      if(MagicFilter != 0 && HistoryDealGetInteger(deal, DEAL_MAGIC) != MagicFilter) continue;

      ulong recPosCheck = (ulong)HistoryDealGetInteger(deal, DEAL_POSITION_ID);
      if(recPosCheck == 0) continue;

      // Skip partial-close exit deals: position still open → backend keys
      // on posId, sending this would wipe the live open_trades row.
      if(PositionSelectByTicket(recPosCheck)) continue;

      bool already = false;
      for(int k = 0; k < qualifiedCount; k++)
      {
         if(qualifiedPositions[k] == recPosCheck) { already = true; break; }
      }
      if(already) continue;

      ArrayResize(qualifiedPositions, qualifiedCount + 1);
      qualifiedPositions[qualifiedCount] = recPosCheck;
      qualifiedCount++;
   }

   string tradesJson = "";
   bool   hasItems   = false;
   int    sentCount  = 0;
   int    confirmedCount = 0;
   int    batchNum   = 0;
   int    batchStartIdx = 0;

   for(int i = 0; i < qualifiedCount; i++)
   {
      if(hasItems) tradesJson += ",";
      tradesJson += BuildClosedJSON(qualifiedPositions[i]);
      hasItems = true;
      sentCount++;

      if(sentCount % 200 == 0)
      {
         batchNum++;
         string batchBody = "{\"account_id\":\"" + g_AccountID + "\",\"trades\":[" + tradesJson + "]}";
         string batchResp = "";
         if(Post("/sync/trades-bulk", batchBody, batchResp))
            confirmedCount += (i + 1) - batchStartIdx;
         else if(DebugLog)
            Print("Oathra: reconcile batch ", batchNum, " upload failed — held");
         tradesJson = "";
         hasItems = false;
         batchStartIdx = i + 1;
         // Keep multi-TP / trailing monitored between batches — reconcile
         // can run for minutes and OnTick is starved while it does.
         MonitorTradeManager();
         // Always print progress so a multi-minute reconcile doesn't
         // look like a hang. DebugLog only gates the upload-failure log.
         PrintFormat("Oathra: reconcile %d / %d (%d%%) batch=%d",
                     sentCount, qualifiedCount,
                     (qualifiedCount > 0 ? (int)(100.0 * sentCount / qualifiedCount) : 0),
                     batchNum);
      }
   }

   if(hasItems)
   {
      string batchBody = "{\"account_id\":\"" + g_AccountID + "\",\"trades\":[" + tradesJson + "]}";
      string batchResp = "";
      if(Post("/sync/trades-bulk", batchBody, batchResp))
         confirmedCount += qualifiedCount - batchStartIdx;
   }

   // Only advance the cursor when at least one batch confirmed. The
   // previous code stamped the latest deal as synced even if every
   // batch POST had failed, leaving server out-of-sync silently.
   if(confirmedCount > 0)
   {
      HistorySelect(0, TimeCurrent());
      for(int i = HistoryDealsTotal() - 1; i >= 0; i--)
      {
         ulong deal = HistoryDealGetTicket(i);
         if(deal == 0) continue;
         ENUM_DEAL_ENTRY entry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(deal, DEAL_ENTRY);
         if(entry == DEAL_ENTRY_OUT || entry == DEAL_ENTRY_INOUT || entry == DEAL_ENTRY_OUT_BY)
         {
            datetime lastTime = (datetime)HistoryDealGetInteger(deal, DEAL_TIME);
            if(lastTime > g_LastClosed
               || (lastTime == g_LastClosed && deal > g_LastClosedDeal))
            {
               g_LastClosed     = lastTime;
               g_LastClosedDeal = deal;
               GlobalVariableSet(g_AccountKey,  (double)g_LastClosed);
               GlobalVariableSet(g_LastDealKey, (double)g_LastClosedDeal);
            }
            break;
         }
      }
   }

   Print("Oathra: Reconciliation complete — ", confirmedCount, " of ", sentCount, " trades confirmed");
   SendNotification("Oathra: History reconciliation complete — " + IntegerToString(confirmedCount) + " trades synced");
   if(EnableAlerts) Alert("Oathra: Reconciliation complete — ", confirmedCount, " trades uploaded");
}

//+------------------------------------------------------------------+
// Poll for pending commands and dispatch each one. Uses string-aware
// scanning so a command field containing `]` or `}` (e.g. inside a
// comment or label) cannot truncate the array or the per-object body.
void CheckCommands()
{
   string response = Get("/commands/pending?account_id=" + g_AccountID);
   if(StringLen(response) == 0) return;
   if(StringFind(response, "\"commands\":[]") >= 0) return;

   int kpos = FindKey(response, "commands");
   int n    = StringLen(response);
   int arrStart = -1;
   if(kpos >= 0)
   {
      string token = "\"commands\":";
      int p = kpos + StringLen(token);
      while(p < n && StringGetCharacter(response, p) == ' ') p++;
      if(p < n && StringGetCharacter(response, p) == '[') arrStart = p;
   }
   if(arrStart < 0) arrStart = StringFind(response, "[");
   if(arrStart < 0) return;
   int arrEnd = FindStructural(response, ']', arrStart + 1);
   if(arrEnd < 0) return;

   int contentLen = arrEnd - arrStart - 1;
   if(contentLen < 5) return;
   string arrContent = StringSubstr(response, arrStart + 1, contentLen);

   int cn = StringLen(arrContent);
   int searchFrom = 0;
   while(searchFrom < cn)
   {
      int objStart = -1;
      for(int i = searchFrom; i < cn; i++)
      {
         ushort ch = StringGetCharacter(arrContent, i);
         if(ch == '"') { int cc = SkipJsonString(arrContent, i); if(cc < 0) return; i = cc; continue; }
         if(ch == '{') { objStart = i; break; }
      }
      if(objStart < 0) break;
      int objEnd = FindStructural(arrContent, '}', objStart + 1);
      if(objEnd < 0) break;

      string cmdJson = StringSubstr(arrContent, objStart, objEnd - objStart + 1);
      ExecuteCommand(cmdJson);
      searchFrom = objEnd + 1;
   }
}

//+------------------------------------------------------------------+
// Central dispatcher. Supports both flat (legacy v3.x) and
// nested-payload (v4.0+) message formats — see GetField below.
void ExecuteCommand(const string cmdJson)
{
   string cmdId     = ExtractJsonString(cmdJson, "id");
   string action    = ExtractJsonString(cmdJson, "action");
   string alertName = ExtractJsonString(cmdJson, "alert_name");

   if(StringLen(action) == 0 || StringLen(cmdId) == 0) return;

   if(WasCommandExecuted(cmdId))
   {
      // If a prior /commands/confirm POST failed, the original body is
      // still in the retry queue — replay it so the app receives the
      // real ticket/fill_price (needed for chained set_tp_levels /
      // trailing_start). If the queue was lost (RAM-only across EA
      // reload), fall back to a dummy "Already executed" confirmation.
      string queuedBody = LoadPendingConfirmBody(cmdId);
      if(StringLen(queuedBody) > 0)
      {
         if(DebugLog) Print("Oathra CMD: replaying queued confirm for ", cmdId);
         string response = "";
         if(Post("/commands/confirm", queuedBody, response) || ConfirmRefusedForGood(cmdId, response))
            DequeuePendingConfirm(cmdId);
         return;
      }

      if(DebugLog) Print("Oathra CMD: skipping already executed command ", cmdId);
      CmdResult dup;  ResetCmdResult(dup);
      dup.message = "Already executed previously";
      ConfirmCommand(cmdId, true, BuildResultJson(dup));
      return;
   }

   bool      success = false;
   CmdResult cr;
   ResetCmdResult(cr);
   string    resultMsg = "";   // non-open handlers populate this; merged into cr.message at end

   // W0-LEASE derives payload.scope server-side from an unforgeable
   // guardian_alert_events row and strips it from every other leased
   // command (02-plan.md §6 D1) — so "account" reaching the wire here
   // already IS the authority. Read it via the nested accessors only:
   // GetFieldStr's top-level fallback is not depth-aware and would
   // match a "scope" key at any nesting depth in the envelope, not
   // only inside payload (MON-D-a).
   string cmdPayload = ExtractNestedJson(cmdJson, "payload");
   bool honourAccountScope = (ExtractJsonString(cmdPayload, "scope") == "account");

   if(action == "open_trade" || action == "open_market")
   {
      string symbol    = GetFieldStr(cmdJson, "symbol");
      string direction = GetFieldStr(cmdJson, "direction");
      double lots      = GetFieldNum(cmdJson, "lots");
      double sl_pips   = GetFieldNum(cmdJson, "sl_pips");
      double tp_pips   = GetFieldNum(cmdJson, "tp_pips");
      double sl_abs    = GetFieldNum(cmdJson, "sl");
      double tp_abs    = GetFieldNum(cmdJson, "tp");
      string comment   = GetFieldStr(cmdJson, "comment");
      long   magicLong = (long)GetFieldNum(cmdJson, "magic");
      int    splits    = (int)GetFieldNum(cmdJson, "split_count");
      // v4.26 (open_safety): the ticket's own tolerances; 0 = none set.
      int    maxDeviation = (int)MathMax(0.0, GetFieldNum(cmdJson, "max_deviation_points"));
      int    maxSpread    = (int)MathMax(0.0, GetFieldNum(cmdJson, "max_spread_points"));
      int    maxQuoteAge  = (int)MathMax(0.0, GetFieldNum(cmdJson, "max_quote_age_s"));
      if(splits < 1) splits = 1;
      if(StringLen(comment) == 0) comment = "Oathra";
      // Range-check magic: MQL5 int is signed 32-bit; out-of-range values
      // would silently truncate and break magic-equality checks. On failure
      // fall through to ConfirmCommand — a bare return would leave the
      // command unconfirmed and the server would re-poll it forever.
      if(magicLong < 0 || magicLong > 2147483647)
      {
         cr.message = StringFormat("magic %d out of range [0, 2147483647]", magicLong);
         success = false;
      }
      else
      {
         int magic = (int)magicLong;
         if(magic == 0) magic = TM_MAGIC_BASE;
         success = CmdOpenMarket(symbol, direction, lots, sl_pips, tp_pips,
                                 sl_abs, tp_abs, comment, magic, splits,
                                 maxDeviation, maxSpread, maxQuoteAge, cr);
      }
   }
   else if(action == "open_pending")
   {
      string symbol    = GetFieldStr(cmdJson, "symbol");
      string orderType = GetFieldStr(cmdJson, "order_type");
      double price     = GetFieldNum(cmdJson, "price");
      double stopLim   = GetFieldNum(cmdJson, "stop_limit_price");
      double lots      = GetFieldNum(cmdJson, "lots");
      double sl        = GetFieldNum(cmdJson, "sl");
      double tp        = GetFieldNum(cmdJson, "tp");
      string comment   = GetFieldStr(cmdJson, "comment");
      long   magicLong = (long)GetFieldNum(cmdJson, "magic");
      datetime expiry  = (datetime)GetFieldNum(cmdJson, "expiration");
      // Range-check magic (see open_market branch above for rationale).
      if(magicLong < 0 || magicLong > 2147483647)
      {
         cr.message = StringFormat("magic %d out of range [0, 2147483647]", magicLong);
         success = false;
      }
      else
      {
         int magic = (int)magicLong;
         success = CmdOpenPending(symbol, orderType, price, stopLim, lots, sl, tp,
                                  comment, magic, expiry, cr);
      }
   }
   else if(action == "modify_pending")
   {
      ulong    ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      double   price  = GetFieldNum(cmdJson, "price");
      double   sl     = GetFieldNum(cmdJson, "sl");
      double   tp     = GetFieldNum(cmdJson, "tp");
      datetime expiry = (datetime)GetFieldNum(cmdJson, "expiration");
      success = CmdModifyPending(ticket, price, sl, tp, expiry, resultMsg);
   }
   else if(action == "delete_pending")
   {
      // v4.33: account scope (server-derived) lifts the ownership half only.
      ulong ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      success = CmdDeletePending(ticket, honourAccountScope, cr);
   }
   else if(action == "delete_all_pending")
   {
      // v4.33: every pending order at account scope, else every one Oathra placed.
      success = CmdDeleteAllPending(honourAccountScope, cr);
   }
   else if(action == "partial_close")
   {
      ulong  ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      double lots   = GetFieldNum(cmdJson, "lots");
      success = CmdPartialClose(ticket, lots, resultMsg);
   }
   else if(action == "close_all")
   {
      // v4.26 (A07-C15, close_opened_before): the kill switch's press
      // instant, UTC epoch ms. Read from payload only — the server strips
      // it from every close that is not a kill-switch close (D7).
      long openedBeforeMs = HasField(cmdPayload, "opened_before_ms")
                            ? (long)ExtractJsonDouble(cmdPayload, "opened_before_ms") : 0;
      success = CmdCloseAll(honourAccountScope, openedBeforeMs, cr);
   }
   else if(action == "close_ticket")
   {
      // v4.33: account scope (a Trading Rules break, server-derived) reaches a
      // trade Oathra did not open; without it the ownership gate stands.
      ulong ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      success = CmdCloseTicket(ticket, honourAccountScope, cr);
   }
   else if(action == "close_symbol")
   {
      string symbol = GetFieldStr(cmdJson, "symbol");
      success = CmdCloseBySymbol(symbol, honourAccountScope, cr);
   }
   else if(action == "close_profitable")
   {
      success = CmdCloseFiltered(true, honourAccountScope, cr);
   }
   else if(action == "close_losing")
   {
      success = CmdCloseFiltered(false, honourAccountScope, cr);
   }
   else if(action == "close_by_magic")
   {
      long magLong = (long)GetFieldNum(cmdJson, "magic");
      if(magLong < 0 || magLong > 2147483647)
      {
         resultMsg = StringFormat("magic %d out of range [0, 2147483647]", magLong);
         success   = false;
      }
      else
      {
         int mag = (int)magLong;
         success = CmdCloseByMagic(mag, honourAccountScope, cr);
      }
   }
   else if(action == "modify_sltp")
   {
      ulong  ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      double sl     = GetFieldNum(cmdJson, "sl");
      double tp     = GetFieldNum(cmdJson, "tp");
      success = CmdModifySLTP(ticket, sl, tp, resultMsg);
   }
   else if(action == "move_sl")
   {
      ulong  ticket     = (ulong)GetFieldNum(cmdJson, "ticket");
      double price      = GetFieldNum(cmdJson, "price");
      // MC08 / MT-05 / C2b: `ticket` is the identifier; resolve to the
      // live POSITION_TICKET before selecting. Lookup only — no
      // ownership gate here, before or after (unchanged; see report).
      ulong  liveTicket = ticket;
      ResolvePositionByIdentifier(ticket, liveTicket);
      if(price <= 0)
      {
         resultMsg = "Invalid price: must be > 0";
         success   = false;
      }
      else if(PositionSelectByTicket(liveTicket))
      {
         success = m_trade.PositionModify(liveTicket, price, PositionGetDouble(POSITION_TP));
         resultMsg = success ? "SL moved" : "Move SL failed";
      }
      else resultMsg = "Position not found";
   }
   else if(action == "move_tp")
   {
      ulong  ticket     = (ulong)GetFieldNum(cmdJson, "ticket");
      double price      = GetFieldNum(cmdJson, "price");
      // MC08 / MT-05 / C2b: same resolve-before-select as move_sl above.
      ulong  liveTicket = ticket;
      ResolvePositionByIdentifier(ticket, liveTicket);
      if(price <= 0)
      {
         resultMsg = "Invalid price: must be > 0";
         success   = false;
      }
      else if(PositionSelectByTicket(liveTicket))
      {
         success = m_trade.PositionModify(liveTicket, PositionGetDouble(POSITION_SL), price);
         resultMsg = success ? "TP moved" : "Move TP failed";
      }
      else resultMsg = "Position not found";
   }
   else if(action == "move_sl_be" || action == "move_sl_be_plus")
   {
      double offset = GetFieldNum(cmdJson, "offset_pips");
      success = CmdMoveSLBreakEven(offset, resultMsg);
   }
   else if(action == "trailing_start")
   {
      ulong  ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      string method = GetFieldStr(cmdJson, "method");
      success = CmdStartTrailing(ticket, method, cmdJson, resultMsg);
   }
   else if(action == "set_tp_levels")
   {
      ulong ticket = (ulong)GetFieldNum(cmdJson, "ticket");
      success = CmdSetTPLevels(ticket, cmdJson, resultMsg);
   }
   else if(action == "stop_trading")
   {
      // Lock new opens. If payload.force_close=true, also close all —
      // account-scope (honourAccountScope) only when W0-LEASE derived
      // it; the kill switch's ordinary lock-only case never does.
      g_TradingLocked = true;
      SaveTMState();   // persist so the lock survives EA reload
      bool forceClose = GetFieldBool(cmdJson, "force_close");
      if(forceClose)
      {
         CmdCloseAll(honourAccountScope, 0, cr);
         cr.message = "Trading locked + " + cr.message;
      }
      else resultMsg = "Trading locked by remote command";
      success = true;
   }
   else if(action == "start_trading")
   {
      g_TradingLocked = false;
      SaveTMState();
      resultMsg = "Trading unlocked by remote command";
      success = true;
   }
   else if(action == "reconcile_history")
   {
      ReconcileHistory();
      success   = true;
      resultMsg = "Trade history reconciliation completed";
   }
   else if(action == "sync_now")
   {
      // A04-C8 (B25): runs the same pipeline OnTimer already drives
      // (account heartbeat, capital flows, trades, pending orders) on
      // demand instead of waiting for the next interval, then confirms
      // like any other command.
      // v4.28 (D4): the periodic timer restarts from here, so the next
      // heartbeat is a full interval later; a heartbeat the server did not
      // accept is reported as a failure, so the app does not wait for it.
      g_LastFullSyncMs  = 0;   // v4.31: Sync now is always a full cycle
      bool synced = SyncAll();
      g_LastSyncMs      = GetTickCount64();
      g_SyncHoldUntilMs = 0;
      success   = synced;
      resultMsg = synced ? "Sync now completed"
                : (StringLen(g_WaitingReason) > 0 ? "Sync now: MetaTrader has not loaded the account"
                                                  : "Sync now: heartbeat not accepted");
   }
   else if(action == "dump_symbols")
   {
      success = CmdDumpSymbols(resultMsg);
   }
   else
   {
      resultMsg = "Unknown action: " + action;
   }

   if(success) MarkCommandExecuted(cmdId);

   // Open-path and bulk-close handlers write cr.message (and, for closes,
   // cr.positions_json/cr.scope_honoured) directly; every other handler
   // still writes resultMsg. Pick whichever is populated so BuildResultJson
   // always has one source for the message.
   if(StringLen(cr.message) == 0) cr.message = resultMsg;

   string notifyMsg = StringLen(alertName) > 0
      ? "Oathra: " + alertName + " — " + cr.message
      : "Oathra: " + action    + " — " + cr.message;
   // Push only for alert-tagged commands (their whole purpose) or for
   // failures (a silently-failed command is worth a push). Routine TM
   // steps — set_tp_levels, trailing_start, move_sl … — must not spam
   // the user's phone with a notification each.
   if(StringLen(alertName) > 0 || !success)
      SendNotification(notifyMsg);
   if(StringLen(alertName) > 0 && EnableAlerts) Alert(notifyMsg);

   ConfirmCommand(cmdId, success, BuildResultJson(cr));

   if(DebugLog) Print("Oathra CMD: ", action, " → ", success ? "OK" : "FAIL", " | ", cr.message);
}

//+------------------------------------------------------------------+
// Pending-confirm queue (bug S5)

// Pass 1 Sol 17: file-persisted pending-confirm queue.
// On every enqueue/dequeue, the queue is flushed to disk so an EA
// reload/crash doesn't lose unconfirmed results. /confirm is idempotent
// (Pass 3 Sol 3 SELECT-then-UPDATE race guard), so re-posting is safe.
// Entries older than 7 days are pruned to prevent unbounded growth.
string PendingConfirmFileName()
{
   long login = AccountInfoInteger(ACCOUNT_LOGIN);
   return "oathra_tm_confirms_" + IntegerToString(login) + ".json";
}

void SavePendingConfirms()
{
   string fname = PendingConfirmFileName();
   int h = FileOpen(fname, FILE_WRITE | FILE_TXT | FILE_UNICODE);
   if(h == INVALID_HANDLE) return;
   string j = "[";
   for(int i = 0; i < g_PendingConfirmCount; i++)
   {
      if(i > 0) j += ",";
      j += "{\"id\":\"" + EscapeJson(g_PendingConfirms[i].cmdId) + "\","
         + "\"body\":\""  + EscapeJson(g_PendingConfirms[i].body) + "\","
         + "\"at\":"      + IntegerToString((long)g_PendingConfirms[i].queuedAt) + "}";
   }
   j += "]";
   FileWriteString(h, j);
   FileClose(h);
}

void LoadPendingConfirms()
{
   string fname = PendingConfirmFileName();
   if(!FileIsExist(fname)) return;
   int h = FileOpen(fname, FILE_READ | FILE_TXT | FILE_UNICODE);
   if(h == INVALID_HANDLE) return;
   string j = "";
   while(!FileIsEnding(h)) j += FileReadString(h);
   FileClose(h);
   if(StringLen(j) < 3) return;

   // Parse the persisted top-level array [{...},{...}] into object tokens.
   string tokens[];
   int n = SplitJsonObjectArray(j, "", tokens); // "" = bare top-level array

   long cutoff = TimeCurrent() - 7 * 86400; // drop entries older than 7 days
   int loaded = 0;
   for(int i = 0; i < n && g_PendingConfirmCount < MAX_PENDING_CONFIRMS; i++)
   {
      string id   = ExtractJsonString(tokens[i], "id");
      // v4.30: SavePendingConfirms escapes the body, so it is unescaped here.
      // Before, it came back still escaped ({\"command_id\"...), the monitor
      // refused it with a 400 on every retry, and each later save escaped
      // it once more.
      string body = UnescapeJson(ExtractJsonString(tokens[i], "body"));
      long   at   = (long)ExtractJsonDouble(tokens[i], "at");
      if(StringLen(id) < 10 || StringLen(body) < 5) continue;
      if(at > 0 && at < cutoff) continue; // prune stale
      ArrayResize(g_PendingConfirms, g_PendingConfirmCount + 1);
      g_PendingConfirms[g_PendingConfirmCount].cmdId    = id;
      g_PendingConfirms[g_PendingConfirmCount].body     = body;
      g_PendingConfirms[g_PendingConfirmCount].queuedAt = (datetime)at;
      g_PendingConfirmCount++;
      loaded++;
   }
   if(loaded > 0)
      Print("Oathra TM: restored ", loaded, " pending confirm(s) from disk");
}

void EnqueuePendingConfirm(string cmdId, string body)
{
   for(int i = 0; i < g_PendingConfirmCount; i++)
   {
      if(g_PendingConfirms[i].cmdId == cmdId)
      {
         g_PendingConfirms[i].body     = body;
         g_PendingConfirms[i].queuedAt = TimeCurrent();
         SavePendingConfirms();
         return;
      }
   }
   if(g_PendingConfirmCount >= MAX_PENDING_CONFIRMS)
   {
      // Drop oldest
      for(int i = 0; i < g_PendingConfirmCount - 1; i++)
         g_PendingConfirms[i] = g_PendingConfirms[i + 1];
      g_PendingConfirmCount--;
   }
   ArrayResize(g_PendingConfirms, g_PendingConfirmCount + 1);
   g_PendingConfirms[g_PendingConfirmCount].cmdId    = cmdId;
   g_PendingConfirms[g_PendingConfirmCount].body     = body;
   g_PendingConfirms[g_PendingConfirmCount].queuedAt = TimeCurrent();
   g_PendingConfirmCount++;
   SavePendingConfirms();
}

void DequeuePendingConfirm(string cmdId)
{
   int idx = -1;
   for(int i = 0; i < g_PendingConfirmCount; i++)
      if(g_PendingConfirms[i].cmdId == cmdId) { idx = i; break; }
   if(idx < 0) return;
   for(int i = idx; i < g_PendingConfirmCount - 1; i++)
      g_PendingConfirms[i] = g_PendingConfirms[i + 1];
   g_PendingConfirmCount--;
   SavePendingConfirms();
}

string LoadPendingConfirmBody(string cmdId)
{
   for(int i = 0; i < g_PendingConfirmCount; i++)
      if(g_PendingConfirms[i].cmdId == cmdId)
         return g_PendingConfirms[i].body;
   return "";
}

// Drain up to 5 pending confirms per call. Called from OnTimer when
// the queue is non-empty.
void RetryPendingConfirms()
{
   int retried = 0;
   for(int i = g_PendingConfirmCount - 1; i >= 0 && retried < 5; i--, retried++)
   {
      string body = g_PendingConfirms[i].body;
      string cmdId = g_PendingConfirms[i].cmdId;
      string response = "";
      if(Post("/commands/confirm", body, response) || ConfirmRefusedForGood(cmdId, response))
         DequeuePendingConfirm(cmdId);
   }
}

//+------------------------------------------------------------------+
// Confirm command outcome back to Railway. resultJson is a literal JSON
// object built by BuildResultJson — contains message + ticket / tickets[]
// / fill_price / lots_filled. The app reads ticket / fill_price after
// open_market to chain set_tp_levels and trailing_start.
//
// On POST failure, the body is queued for retry — the app's chained
// commands depend on this confirmation actually arriving.
void ConfirmCommand(string cmdId, bool success, string resultJson)
{
   string body = "{\"command_id\":\"" + EscapeJson(cmdId) + "\","
               + "\"status\":\""      + (success ? "executed" : "failed") + "\","
               + "\"result\":"        + resultJson + "}";
   string response = "";
   if(Post("/commands/confirm", body, response))
      DequeuePendingConfirm(cmdId);
   else if(ConfirmRefusedForGood(cmdId, response))
      DequeuePendingConfirm(cmdId);
   else
      EnqueuePendingConfirm(cmdId, body);
}

// v4.30: a confirm the monitor refused with a 4xx (bad body, unknown or
// already-settled command) is refused the same way on every retry, so it
// leaves the queue instead of being posted every cycle forever. A 401, 408,
// 423 or 429 is a pause, not a verdict, and the confirm is kept. Call only
// right after a failed Post("/commands/confirm", ...).
bool ConfirmRefusedForGood(const string cmdId, const string response)
{
   int code = g_LastHttpCode;
   if(code < 400 || code >= 500) return false;
   if(code == 401 || code == 408 || code == 423 || code == 429) return false;
   Print("Oathra CMD: confirm for ", cmdId, " refused (HTTP ", code, "), dropped. ", response);
   return true;
}

string CommandExecutionKey(string cmdId)
{
   // cmdId is a 36-char UUID; g_CommandExecPrefix + the raw UUID overruns
   // MT5's 63-char GlobalVariable name cap, so GlobalVariableSet would
   // silently fail and the de-dup marker would never persist. Fold the
   // UUID to a 16-hex digest (two FNV passes ≈ 64-bit) so the key fits.
   return g_CommandExecPrefix
        + StringFormat("%08X%08X", StringHashSimple(cmdId),
                                   StringHashSimple(cmdId + "_oa"));
}

bool WasCommandExecuted(string cmdId)
{
   return StringLen(cmdId) > 0 && GlobalVariableCheck(CommandExecutionKey(cmdId));
}

void MarkCommandExecuted(string cmdId)
{
   if(StringLen(cmdId) == 0) return;
   GlobalVariableSet(CommandExecutionKey(cmdId), (double)TimeCurrent());
}

void PruneCommandExecutionMarkers()
{
   datetime cutoff = TimeCurrent() - 7 * 24 * 60 * 60;
   for(int i = GlobalVariablesTotal() - 1; i >= 0; i--)
   {
      string name = GlobalVariableName(i);
      if(StringFind(name, g_CommandExecPrefix) == 0)
      {
         datetime markedAt = (datetime)(long)GlobalVariableGet(name);
         if(markedAt < cutoff) GlobalVariableDel(name);
      }
   }
}

//==================================================================
//  Command handlers
//==================================================================

//+------------------------------------------------------------------+
// Open market order. Supports pip-based or absolute SL/TP, splits,
// custom magic. Honors g_TradingLocked. Populates `out` with
// ticket / tickets_csv / fill_price / lots_filled so the app can
// chain set_tp_levels + trailing_start.
bool CmdOpenMarket(string symbol, string direction, double totalLots,
                   double sl_pips, double tp_pips,
                   double sl_abs, double tp_abs,
                   string comment, int magic, int splits,
                   int maxDeviationPoints, int maxSpreadPoints, int maxQuoteAgeS,
                   CmdResult &out)
{
   if(g_TradingLocked) { out.message = "Trading is locked"; return false; }
   if(!m_sym.Name(symbol)) { out.message = "Symbol not found: " + symbol; return false; }
   if(totalLots <= 0) { out.message = "Invalid lots: must be > 0"; return false; }
   if(direction != "buy" && direction != "sell") {
      out.message = "Invalid direction: '" + direction + "' (expected 'buy' or 'sell')";
      return false;
   }
   if(splits < 1)  splits = 1;
   if(splits > 10) splits = 10;   // cap: malformed payloads can't fire 1000 orders
   // Ensure the symbol is in Market Watch — for an off-watch symbol
   // RefreshRates()/Ask()/Bid() can return 0 and produce a bad entry.
   SymbolSelect(symbol, true);
   m_sym.RefreshRates();

   // v4.26 (A07-C15, open_safety): refuse before sending when the ticket's
   // own tolerances are already broken. A zero tolerance means the ticket
   // set none, never "refuse everything".
   if(maxSpreadPoints > 0)
   {
      long spread = SymbolInfoInteger(symbol, SYMBOL_SPREAD);
      if(spread > maxSpreadPoints)
      {
         out.code    = "spread_too_wide";
         out.message = StringFormat("Spread %d points is wider than the %d allowed", spread, maxSpreadPoints);
         return false;
      }
   }
   if(maxQuoteAgeS > 0)
   {
      MqlTick lastTick;
      bool    haveTick = SymbolInfoTick(symbol, lastTick) && lastTick.time > 0;
      long    ageS     = haveTick ? (long)(TimeTradeServer() - lastTick.time) : -1;
      if(!haveTick || ageS > maxQuoteAgeS)
      {
         out.code    = "quote_stale";
         out.message = haveTick
            ? StringFormat("Last quote is %d s old, more than the %d allowed", ageS, maxQuoteAgeS)
            : "No quote for " + symbol;
         return false;
      }
   }

   ENUM_ORDER_TYPE type = (direction == "buy") ? ORDER_TYPE_BUY : ORDER_TYPE_SELL;
   double entry = (type == ORDER_TYPE_BUY) ? m_sym.Ask() : m_sym.Bid();

   // Pip-based SL/TP if absolute not provided
   double pipSize = GetPipSize(symbol);
   double slPrice = sl_abs;
   double tpPrice = tp_abs;
   if(slPrice == 0 && sl_pips > 0)
      slPrice = (type == ORDER_TYPE_BUY) ? entry - sl_pips * pipSize : entry + sl_pips * pipSize;
   if(tpPrice == 0 && tp_pips > 0)
      tpPrice = (type == ORDER_TYPE_BUY) ? entry + tp_pips * pipSize : entry - tp_pips * pipSize;

   ValidateStopLevels(symbol, entry, slPrice, tpPrice);
   int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   slPrice = NormalizeDouble(slPrice, digits);
   tpPrice = NormalizeDouble(tpPrice, digits);

   double lotPerSplit = NormalizeLots(symbol, totalLots / splits);
   if(lotPerSplit <= 0)
   {
      out.code     = "below_min_lot";
      out.min_lots = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN) * splits;
      out.message  = StringFormat("Below the broker minimum of %s lots", DoubleToString(out.min_lots, 2));
      return false;
   }

   m_trade.SetExpertMagicNumber(magic);
   m_trade.SetTypeFilling(GetFillingMode(symbol));
   m_trade.SetDeviationInPoints(maxDeviationPoints > 0 ? maxDeviationPoints : DEFAULT_DEVIATION_POINTS);

   string tickets   = "";
   int    filled    = 0;
   ulong  startMs   = GetTickCount64();
   ulong  firstTkt  = 0;
   double firstFill = 0;
   double filledLots = 0;
   bool   anyPartialFill = false;

   for(int i = 0; i < splits; i++)
   {
      string splitComment = (splits > 1)
         ? StringFormat("%s [%d/%d]", comment, i + 1, splits)
         : comment;
      // MT5 broker comment field is capped at ~31 chars on most brokers;
      // longer strings are silently truncated. Trim explicitly so the
      // user's deal logs match what they sent.
      if(StringLen(splitComment) > 31)
      {
         if(DebugLog)
            Print("Oathra: comment truncated from ", StringLen(splitComment), " to 31 chars");
         splitComment = StringSubstr(splitComment, 0, 31);
      }

      bool ok = false;
      for(int retry = 0; retry < MAX_RETRY; retry++)
      {
         m_sym.RefreshRates();
         entry = (type == ORDER_TYPE_BUY) ? m_sym.Ask() : m_sym.Bid();

         ok = (type == ORDER_TYPE_BUY)
            ? m_trade.Buy (lotPerSplit, symbol, entry, slPrice, tpPrice, splitComment)
            : m_trade.Sell(lotPerSplit, symbol, entry, slPrice, tpPrice, splitComment);
         if(ok) break;

         uint retcode = m_trade.ResultRetcode();
         if(retcode == TRADE_RETCODE_REQUOTE || retcode == TRADE_RETCODE_PRICE_CHANGED)
         { Sleep(50); continue; }
         break;
      }

      if(ok)
      {
         filled++;
         ulong ticket = m_trade.ResultOrder();
         if(filled == 1) {
            firstTkt  = ticket;
            firstFill = m_trade.ResultPrice();   // realized fill of first split
            // ResultPrice() can be 0 on some brokers' return-mode fills.
            // Capture the live quote at the moment of fill rather than
            // the last retry-loop snapshot, which can be stale by 50ms+.
            if(firstFill <= 0)
               firstFill = (type == ORDER_TYPE_BUY)
                  ? SymbolInfoDouble(symbol, SYMBOL_ASK)
                  : SymbolInfoDouble(symbol, SYMBOL_BID);
         }
         if(StringLen(tickets) > 0) tickets += ",";
         tickets += IntegerToString(ticket);
         TrackTmTicket(ticket);  // Pass 2 Sol 2
         // v4.26: count what actually filled. TRADE_RETCODE_DONE_PARTIAL
         // reports less than was asked; a broker that omits ResultVolume()
         // on a full fill reads as the full split.
         double got = m_trade.ResultVolume();
         if(got <= 0) got = lotPerSplit;
         if(m_trade.ResultRetcode() == TRADE_RETCODE_DONE_PARTIAL) anyPartialFill = true;
         filledLots += got;
      }
   }

   // Reset shared CTrade magic and slippage so subsequent handler calls
   // don't inherit this command's custom values.
   m_trade.SetExpertMagicNumber(TM_MAGIC_BASE);
   m_trade.SetDeviationInPoints(DEFAULT_DEVIATION_POINTS);

   ulong execMs = GetTickCount64() - startMs;

   if(filled == 0)
   {
      out.message = StringFormat("All %d orders failed. Last error: %d",
                                 splits, m_trade.ResultRetcode());
      return false;
   }

   double requestedLots = lotPerSplit * splits;
   out.ticket      = firstTkt;
   out.tickets_csv = tickets;
   out.fill_price  = firstFill;
   out.lots_filled = NormalizeDouble(filledLots, 8);
   // Half the smallest step is the float-dust margin: anything short of
   // that is a real shortfall, and the confirm says so (partial).
   if(anyPartialFill || out.lots_filled < requestedLots - SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP) / 2)
   {
      out.partial        = true;
      out.requested_lots = NormalizeDouble(requestedLots, 8);
   }
   out.message = StringFormat("%s %s %.2f %s @ %.5f | %d/%d filled | tickets:[%s] | magic:%d | %I64ums",
      out.partial ? "Partial" : "Opened",
      direction, out.lots_filled, symbol, firstFill, filled, splits, tickets, magic, execMs);
   return true;
}

//+------------------------------------------------------------------+
// stopLimitPrice is required for buy_stop_limit / sell_stop_limit
// (price = stop trigger, stopLimitPrice = limit price after trigger).
// Pre-4.20 hardcoded the 4th OrderOpen arg to 0 — broker rejected those
// types unconditionally.
bool CmdOpenPending(string symbol, string orderType, double price,
                    double stopLimitPrice, double lots,
                    double sl, double tp, string comment, int magic,
                    datetime expiration, CmdResult &out)
{
   if(g_TradingLocked) { out.message = "Trading is locked"; return false; }
   if(lots <= 0) { out.message = "Invalid lots: must be > 0"; return false; }

   lots = NormalizeLots(symbol, lots);
   if(lots <= 0)
   {
      out.code     = "below_min_lot";
      out.min_lots = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
      out.message  = StringFormat("Below the broker minimum of %s lots", DoubleToString(out.min_lots, 2));
      return false;
   }

   int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   price = NormalizeDouble(price, digits);
   if(stopLimitPrice > 0) stopLimitPrice = NormalizeDouble(stopLimitPrice, digits);

   ValidateStopLevels(symbol, price, sl, tp);
   sl = NormalizeDouble(sl, digits);
   tp = NormalizeDouble(tp, digits);

   m_trade.SetExpertMagicNumber(magic > 0 ? magic : TM_MAGIC_BASE);
   m_trade.SetTypeFilling(ORDER_FILLING_RETURN);

   ENUM_ORDER_TYPE type;
   if(orderType == "buy_limit")            type = ORDER_TYPE_BUY_LIMIT;
   else if(orderType == "sell_limit")      type = ORDER_TYPE_SELL_LIMIT;
   else if(orderType == "buy_stop")        type = ORDER_TYPE_BUY_STOP;
   else if(orderType == "sell_stop")       type = ORDER_TYPE_SELL_STOP;
   else if(orderType == "buy_stop_limit")  type = ORDER_TYPE_BUY_STOP_LIMIT;
   else if(orderType == "sell_stop_limit") type = ORDER_TYPE_SELL_STOP_LIMIT;
   else { out.message = "Unknown order type: " + orderType; return false; }

   if((type == ORDER_TYPE_BUY_STOP_LIMIT || type == ORDER_TYPE_SELL_STOP_LIMIT)
      && stopLimitPrice <= 0)
   {
      out.message = "stop_limit_price required for stop-limit pending orders";
      m_trade.SetExpertMagicNumber(TM_MAGIC_BASE);
      return false;
   }

   // Pass 4 Sol 3: webapp sends UTC epoch; MQL5 datetime is broker-server-time.
   // Convert by adding the EA's current UTC offset (TimeCurrent() − TimeGMT()).
   // DST edge: offset is computed at execution time, not at order-creation time.
   // Most brokers change DST on weekends — acceptable v1 risk (see sol file).
   // NOTE: if expirationServer is in the past the broker will reject immediately;
   // the monitor-side past-check (Pass 1 Sol 12) catches obvious cases before
   // burning the command slot.
   datetime expirationServer = expiration;
   if(expiration > 0)
   {
      datetime serverOffset = TimeCurrent() - TimeGMT();
      expirationServer = expiration + serverOffset;
   }

   bool ok = m_trade.OrderOpen(symbol, type, lots, stopLimitPrice, price, sl, tp,
      (expirationServer > 0) ? ORDER_TIME_SPECIFIED : ORDER_TIME_GTC,
      expirationServer,
      StringLen(comment) > 0 ? comment : "Oathra");

   // Reset shared CTrade magic so subsequent close/modify ops don't
   // inherit the per-order magic and write inconsistent deal-log entries.
   m_trade.SetExpertMagicNumber(TM_MAGIC_BASE);

   if(!ok)
   {
      out.message = StringFormat("Pending order failed: %d", m_trade.ResultRetcode());
      return false;
   }

   ulong ticket = m_trade.ResultOrder();
   TrackTmTicket(ticket);  // Pass 2 Sol 2
   out.ticket      = ticket;
   out.tickets_csv = IntegerToString(ticket);
   out.fill_price  = price;            // pending price; broker fills at this level
   out.lots_filled = lots;
   out.message = StringFormat("Pending %s %s %.2f @ %.5f ticket=%d",
                              orderType, symbol, lots, price, ticket);
   return true;
}

//+------------------------------------------------------------------+
bool CmdModifyPending(ulong ticket, double price, double sl, double tp,
                      datetime expiry, string &resultMsg)
{
   // Ownership gate — IsOrderCloseable does the OrderSelect and leaves the
   // order selected for the OrderGet* reads below.
   if(!IsOrderCloseable(ticket, resultMsg)) return false;

   string sym = OrderGetString(ORDER_SYMBOL);
   int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
   if(price > 0) price = NormalizeDouble(price, digits);
   else          price = OrderGetDouble(ORDER_PRICE_OPEN);
   if(sl > 0)    sl = NormalizeDouble(sl, digits);
   else          sl = OrderGetDouble(ORDER_SL);
   if(tp > 0)    tp = NormalizeDouble(tp, digits);
   else          tp = OrderGetDouble(ORDER_TP);

   ValidateStopLevels(sym, price, sl, tp);

   bool ok = m_trade.OrderModify(ticket, price, sl, tp,
                                 (expiry > 0) ? ORDER_TIME_SPECIFIED : ORDER_TIME_GTC,
                                 expiry);
   resultMsg = ok ? StringFormat("Modified order %d", ticket) : "Modify failed";
   return ok;
}

//+------------------------------------------------------------------+
bool CmdPartialClose(ulong ticket, double lots, string &resultMsg)
{
   if(!IsTicketCloseable(ticket, resultMsg)) return false;  // Pass 1 Sol 1
   if(lots <= 0) { resultMsg = "Invalid lots: must be > 0"; return false; }

   string sym = PositionGetString(POSITION_SYMBOL);
   lots = NormalizeLots(sym, lots);
   if(lots <= 0) { resultMsg = "Lot size below broker minimum"; return false; }
   double remaining = PositionGetDouble(POSITION_VOLUME);

   m_trade.SetTypeFilling(GetFillingMode(sym));
   if(lots >= remaining)
   {
      bool ok = m_trade.PositionClose(ticket);
      resultMsg = ok ? StringFormat("Closed entire position %d (%.2f lots)", ticket, remaining)
                     : "Close failed";
      return ok;
   }

   bool ok = m_trade.PositionClosePartial(ticket, lots);
   resultMsg = ok ? StringFormat("Partial close %d: %.2f of %.2f lots", ticket, lots, remaining)
                  : "Partial close failed";
   return ok;
}

//+------------------------------------------------------------------+
// Authority guard for bulk-close handlers. The remote `close_all`
// command must never wipe positions opened by another EA on the same
// terminal. A position counts as Oathra-owned when:
//   - it is currently tracked in the multi-TP or trailing arrays, OR
//   - MagicFilter is set and its magic matches, OR
//   - MagicFilter is unset and its magic matches TM_MAGIC_BASE
// (the EA's default for opens with no explicit magic param).
//+------------------------------------------------------------------+
// Pass 1 Sol 7: GetNetPnl — canonical net P&L including swap.
// POSITION_COMMISSION does not exist in MT5 (commission is rolled
// into POSITION_PROFIT on most builds). Use this helper everywhere
// a position's economic profit is required so CmdMoveSLBreakEven
// and the trailing-stop peak-PnL update stay consistent.
double GetNetPnl(ulong ticket)
{
   if(!PositionSelectByTicket(ticket)) return 0.0;
   return PositionGetDouble(POSITION_PROFIT) + PositionGetDouble(POSITION_SWAP);
}

//+------------------------------------------------------------------+
// Pass 2 Sol 2: record a ticket in the TM-opened history so
// IsOathraOwned() recognises it even when a custom magic was used.
void TrackTmTicket(ulong ticket)
{
   if(ticket == 0) return;
   // de-dup
   for(int i = 0; i < g_TmOpenedTicketsCount; i++)
      if(g_TmOpenedTickets[i] == ticket) return;
   if(g_TmOpenedTicketsCount >= MAX_TM_TICKETS)
   {
      // Ring-overwrite: drop the oldest entry (index 0) and shift left.
      for(int i = 1; i < g_TmOpenedTicketsCount; i++)
         g_TmOpenedTickets[i - 1] = g_TmOpenedTickets[i];
      g_TmOpenedTicketsCount--;
   }
   g_TmOpenedTickets[g_TmOpenedTicketsCount] = ticket;
   g_TmOpenedTicketsCount++;
}

//+------------------------------------------------------------------+
bool IsOathraOwned(ulong ticket, long magic)
{
   for(int i = 0; i < g_TPCount;    i++) if(g_TP[i].ticket    == ticket) return true;
   for(int i = 0; i < g_TrailCount; i++) if(g_Trail[i].ticket == ticket) return true;
   // Pass 2 Sol 2: tickets explicitly opened by a TM command are always owned,
   // regardless of custom magic number.
   for(int i = 0; i < g_TmOpenedTicketsCount; i++)
      if(g_TmOpenedTickets[i] == ticket) return true;
   if(MagicFilter != 0) return (magic == MagicFilter);
   return (magic == TM_MAGIC_BASE);
}

//+------------------------------------------------------------------+
// MC08 / MT-05 / C2b: resolve a POSITION_IDENTIFIER — the value the
// database's open_trades.ticket holds forever, per BuildOpenJSON /
// BuildClosedJSON — to the broker's live POSITION_TICKET, which
// PositionSelectByTicket, PositionClose and PositionModify all require
// and which a netting-account broker reassigns after a partial close.
// Lookup only: never selects a position outside this scan, never
// closes or modifies anything. Returns false, leaving currentTicket
// untouched, when no live position carries this identifier (it has
// already been fully closed).
bool ResolvePositionByIdentifier(const ulong identifier, ulong &currentTicket)
{
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      ulong candidate = PositionGetTicket(i);
      if(candidate == 0) continue;
      if((ulong)PositionGetInteger(POSITION_IDENTIFIER) == identifier)
      {
         currentTicket = candidate;
         return true;
      }
   }
   return false;
}

//+------------------------------------------------------------------+
// Pass 1 Sol 1: ownership gate for single-ticket handlers.
// Returns false (with resultMsg set) when the ticket was not opened by
// a TM command AND does not match the magic filter, so we never
// accidentally modify a position from another EA or manual trade.
//
// MC08 / MT-05 / C2b: `ticket` arrives as the identifier (see
// ResolvePositionByIdentifier above) — resolve it to the live ticket
// before selecting, so a position that has been partially closed since
// it was opened is still found. The ownership check still runs on the
// identifier: g_TP[], g_Trail[] and g_TmOpenedTickets[] are all keyed
// on it (set from this same wire value, which never changes for a
// position's life), never on a live ticket that can be reassigned.
// On success, `ticket` is rewritten in place to the live ticket, which
// is what every caller needs for its own PositionClose/PositionModify —
// this is the only line that changes for CmdCloseTicket, CmdModifySLTP
// and CmdPartialClose; none of the three store a ticket anywhere, so
// none of the three needed any other edit.
bool IsTicketCloseable(ulong &ticket, string &resultMsg)
{
   ulong identifier     = ticket;
   ulong resolvedTicket = identifier;
   ResolvePositionByIdentifier(identifier, resolvedTicket);
   if(!PositionSelectByTicket(resolvedTicket)) { resultMsg = "Position not found"; return false; }
   long magic = PositionGetInteger(POSITION_MAGIC);
   if(!IsOathraOwned(identifier, magic))
   {
      resultMsg = StringFormat("Ticket %I64u is not owned by Oathra TM (magic=%d) — command rejected",
                               identifier, magic);
      return false;
   }
   ticket = resolvedTicket;
   return true;
}

//+------------------------------------------------------------------+
// Ownership gate for pending-order handlers (delete_pending / modify_pending).
// IsTicketCloseable can't be reused — it starts with PositionSelectByTicket,
// which fails for orders. A pending order counts as Oathra-owned when:
//   - it is in the TM-opened ticket history (CmdOpenPending → TrackTmTicket), OR
//   - MagicFilter is set and its magic matches, OR
//   - MagicFilter is unset and its magic matches TM_MAGIC_BASE.
// Without this a remote command could delete/modify any pending order on the
// terminal, including another EA's or a manually-placed one.
bool IsOrderCloseable(ulong ticket, string &resultMsg)
{
   if(!OrderSelect(ticket)) { resultMsg = "Order not found"; return false; }
   for(int i = 0; i < g_TmOpenedTicketsCount; i++)
      if(g_TmOpenedTickets[i] == ticket) return true;
   long magic = OrderGetInteger(ORDER_MAGIC);
   if(MagicFilter != 0) { if(magic == MagicFilter) return true; }
   else if(magic == TM_MAGIC_BASE) return true;
   resultMsg = StringFormat("Order %I64u is not owned by Oathra TM (magic=%d) — command rejected",
                            ticket, magic);
   return false;
}

//+------------------------------------------------------------------+
// Appends one position's close attempt to the running positions[]
// array literal (GUARDIAN.md §6.3: one line per position touched,
// keyed on POSITION_IDENTIFIER, in lots). `positionId` must already
// have been read via PositionGetInteger(POSITION_IDENTIFIER) — the
// position disappears from PositionsTotal() the instant it closes
// and cannot be read back afterward. Called only for a position the
// handler actually attempted to close; a position skipped for
// ownership never gets a line, because it was never touched.
//
// v4.33: this and the three result builders after it are one exported
// region (CommandResultGolden.mq5): pure, every value handed in by the
// broker-touching handler.
// @export-begin CloseResultLines
void AppendPositionResult(CmdResult &r, const ulong positionId, const string symbol,
                           const double lotsRequested, const double lotsClosed,
                           const double price, const int retcode)
{
   if(StringLen(r.positions_json) > 0) r.positions_json += ",";
   r.positions_json += StringFormat(
      "{\"position_id\":\"%I64u\",\"symbol\":\"%s\",\"lots_requested\":%s,"
      + "\"lots_closed\":%s,\"price\":%s,\"retcode\":%d}",
      positionId, EscapeJson(symbol),
      DoubleToString(lotsRequested, 2), DoubleToString(lotsClosed, 2),
      DoubleToString(price, 5), retcode);
}

// v4.33: close_ticket's answer once PositionClose has run. `lotsReported` is
// m_trade.ResultVolume(); a broker that omits it on a close that went
// through reads as the whole position, as the close family does. A refused
// close closed nothing. The retcode goes on the line and at the top level.
bool TicketCloseOutcome(CmdResult &out, const ulong positionId, const ulong liveTicket,
                        const string symbol, const double lotsRequested,
                        const double lotsReported, const bool ok,
                        const double price, const int retcode)
{
   double closedLots = lotsReported;
   if(ok && closedLots <= 0) closedLots = lotsRequested;
   if(!ok) closedLots = 0.0;
   AppendPositionResult(out, positionId, symbol, lotsRequested, closedLots, price, retcode);
   out.retcode     = retcode;
   out.retcode_set = true;
   out.message = ok ? StringFormat("Closed ticket %I64u", liveTicket)
                    : StringFormat("Close failed on ticket %I64u, retcode %d", liveTicket, retcode);
   return ok;
}

// v4.33: one pending order's delete. The order ticket stands as position_id,
// its volume as the lots, its price as the price; a deleted order "closed"
// all its lots, a refused one none. `single` (delete_pending) also sets the
// top-level retcode and the message; delete_all_pending writes its own.
bool PendingDeleteOutcome(CmdResult &out, const ulong orderTicket, const string symbol,
                          const double lots, const double price, const bool ok,
                          const int retcode, const bool single)
{
   AppendPositionResult(out, orderTicket, symbol, lots, ok ? lots : 0.0, price, retcode);
   if(single)
   {
      out.retcode     = retcode;
      out.retcode_set = true;
      out.message = ok ? StringFormat("Deleted pending order %I64u", orderTicket)
                       : StringFormat("Delete failed on order %I64u, retcode %d", orderTicket, retcode);
   }
   return ok;
}

// v4.33: delete_all_pending's message and outcome. Nothing to delete is a
// success; orders tried and none deleted is a failure.
bool PendingDeleteAllSummary(CmdResult &out, const int deleted, const int attempted, const int skipped)
{
   out.message = StringFormat("Deleted %d of %d pending orders", deleted, attempted);
   if(skipped > 0)
      out.message += StringFormat("; %d skipped (not placed by Oathra)", skipped);
   return deleted > 0 || attempted == 0;
}
// @export-end

//+------------------------------------------------------------------+
// `honourAccountScope` comes from W0-LEASE's server-derived
// payload.scope=="account" (GUARDIAN.md §6.2) — when true the
// ownership gate is bypassed so this closes every open position,
// regardless of magic. When false, behaviour is exactly as before:
// only Oathra-owned positions close. `out.scope_honoured` records
// which of the two this specific close actually ran as.
//
// v4.26 (A07-C15): `openedBeforeMs` > 0 is the kill switch's press instant
// (UTC epoch ms). A position that opened more than CLOSE_PRESS_TOLERANCE_MS
// after it is left open and listed in skipped_opened_after — the trader
// pressed before that trade existed. The tolerance errs toward closing.
bool CmdCloseAll(const bool honourAccountScope, const long openedBeforeMs, CmdResult &out)
{
   out.scope_honoured = honourAccountScope;
   // POSITION_TIME_MSC is server time. The server's offset from UTC is a
   // whole number of quarter hours, so rounding to 15 min drops the few
   // seconds a quiet feed's last tick lags behind.
   long serverOffsetMs = (long)MathRound((double)(TimeTradeServer() - TimeGMT()) / 900.0) * 900 * 1000;
   int closed = 0, skipped = 0, openedAfter = 0, total = PositionsTotal();
   for(int i = total - 1; i >= 0; i--)
   {
      if(!m_pos.SelectByIndex(i)) continue;
      if(!honourAccountScope && !IsOathraOwned(m_pos.Ticket(), m_pos.Magic())) { skipped++; continue; }
      if(openedBeforeMs > 0)
      {
         long openedUtcMs = (long)PositionGetInteger(POSITION_TIME_MSC) - serverOffsetMs;
         if(openedUtcMs > openedBeforeMs + CLOSE_PRESS_TOLERANCE_MS)
         {
            if(StringLen(out.skipped_opened_after_csv) > 0) out.skipped_opened_after_csv += ",";
            out.skipped_opened_after_csv += "\"" + IntegerToString(PositionGetInteger(POSITION_IDENTIFIER)) + "\"";
            openedAfter++;
            continue;
         }
      }

      ulong  posId = (ulong)PositionGetInteger(POSITION_IDENTIFIER);  // before close — see above
      string sym   = m_pos.Symbol();
      double vol   = m_pos.Volume();

      m_trade.SetTypeFilling(GetFillingMode(sym));
      bool ok = m_trade.PositionClose(m_pos.Ticket());
      if(ok) closed++;

      double closedLots = m_trade.ResultVolume();
      if(ok && closedLots <= 0) closedLots = vol;  // some brokers omit ResultVolume() on close
      if(!ok) closedLots = 0.0;
      AppendPositionResult(out, posId, sym, vol, closedLots, m_trade.ResultPrice(), (int)m_trade.ResultRetcode());
   }
   out.message = (skipped > 0)
      ? StringFormat("Closed %d of %d positions; %d skipped (not owned by Oathra)",
                     closed, total - skipped - openedAfter, skipped)
      : StringFormat("Closed %d of %d positions", closed, total - openedAfter);
   if(openedAfter > 0)
      out.message += StringFormat("; %d left open (opened after the press)", openedAfter);
   return closed > 0 || total - skipped - openedAfter == 0;
}

//+------------------------------------------------------------------+
bool CmdCloseBySymbol(const string symbol, const bool honourAccountScope, CmdResult &out)
{
   out.scope_honoured = honourAccountScope;
   int closed = 0, skipped = 0, matched = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      if(!m_pos.SelectByIndex(i)) continue;
      if(m_pos.Symbol() != symbol) continue;
      matched++;
      if(!honourAccountScope && !IsOathraOwned(m_pos.Ticket(), m_pos.Magic())) { skipped++; continue; }

      ulong  posId = (ulong)PositionGetInteger(POSITION_IDENTIFIER);  // before close
      double vol   = m_pos.Volume();

      m_trade.SetTypeFilling(GetFillingMode(symbol));
      bool ok = m_trade.PositionClose(m_pos.Ticket());
      if(ok) closed++;

      double closedLots = m_trade.ResultVolume();
      if(ok && closedLots <= 0) closedLots = vol;
      if(!ok) closedLots = 0.0;
      AppendPositionResult(out, posId, symbol, vol, closedLots, m_trade.ResultPrice(), (int)m_trade.ResultRetcode());
   }
   out.message = (skipped > 0)
      ? StringFormat("Closed %d %s positions; %d skipped (not owned by Oathra)",
                     closed, symbol, skipped)
      : StringFormat("Closed %d %s positions", closed, symbol);
   return closed > 0;
}

//+------------------------------------------------------------------+
// v4.33: `honourAccountScope` (server-derived, a Trading Rules break naming
// this command) skips IsOathraOwned only: the identifier is still resolved
// to the live ticket and the position still selected. Without it, the
// ownership gate stands exactly as before (Pass 1 Sol 1). A position that is
// no longer there answers "Position not found", which the server reads as
// already closed.
bool CmdCloseTicket(const ulong ticket, const bool honourAccountScope, CmdResult &out)
{
   out.scope_honoured = honourAccountScope;
   ulong liveTicket = ticket;
   if(honourAccountScope)
   {
      ResolvePositionByIdentifier(ticket, liveTicket);
      if(!PositionSelectByTicket(liveTicket)) { out.message = "Position not found"; return false; }
   }
   else
   {
      string refusal = "";
      if(!IsTicketCloseable(liveTicket, refusal)) { out.message = refusal; return false; }
   }
   ulong  posId = (ulong)PositionGetInteger(POSITION_IDENTIFIER);  // before close: it vanishes after
   string sym   = PositionGetString(POSITION_SYMBOL);
   double vol   = PositionGetDouble(POSITION_VOLUME);
   m_trade.SetTypeFilling(GetFillingMode(sym));
   bool ok = m_trade.PositionClose(liveTicket);
   return TicketCloseOutcome(out, posId, liveTicket, sym, vol, m_trade.ResultVolume(), ok,
                             m_trade.ResultPrice(), (int)m_trade.ResultRetcode());
}

// v4.33: one pending order. Account scope lifts IsOrderCloseable's ownership
// half only: the order must still exist.
bool CmdDeletePending(const ulong ticket, const bool honourAccountScope, CmdResult &out)
{
   out.scope_honoured = honourAccountScope;
   if(honourAccountScope)
   {
      if(!OrderSelect(ticket)) { out.message = "Order not found"; return false; }
   }
   else
   {
      string refusal = "";
      if(!IsOrderCloseable(ticket, refusal)) { out.message = refusal; return false; }
   }
   string sym   = OrderGetString(ORDER_SYMBOL);
   double lots  = OrderGetDouble(ORDER_VOLUME_CURRENT);
   double price = OrderGetDouble(ORDER_PRICE_OPEN);
   bool ok = m_trade.OrderDelete(ticket);
   return PendingDeleteOutcome(out, ticket, sym, lots, price, ok, (int)m_trade.ResultRetcode(), true);
}

// v4.33: every pending order at account scope, else every one Oathra placed
// (IsOrderCloseable). Backwards over OrdersTotal(): a delete shrinks the list.
bool CmdDeleteAllPending(const bool honourAccountScope, CmdResult &out)
{
   out.scope_honoured = honourAccountScope;
   int deleted = 0, attempted = 0, skipped = 0;
   for(int i = OrdersTotal() - 1; i >= 0; i--)
   {
      ulong ticket = OrderGetTicket(i);
      if(ticket == 0) continue;
      if(!honourAccountScope)
      {
         string refusal = "";
         if(!IsOrderCloseable(ticket, refusal)) { skipped++; continue; }
      }
      string sym   = OrderGetString(ORDER_SYMBOL);
      double lots  = OrderGetDouble(ORDER_VOLUME_CURRENT);
      double price = OrderGetDouble(ORDER_PRICE_OPEN);
      attempted++;
      bool ok = m_trade.OrderDelete(ticket);
      if(ok) deleted++;
      PendingDeleteOutcome(out, ticket, sym, lots, price, ok, (int)m_trade.ResultRetcode(), false);
   }
   return PendingDeleteAllSummary(out, deleted, attempted, skipped);
}

//+------------------------------------------------------------------+
bool CmdCloseFiltered(const bool profitableOnly, const bool honourAccountScope, CmdResult &out)
{
   out.scope_honoured = honourAccountScope;
   int closed = 0, skipped = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      if(!m_pos.SelectByIndex(i)) continue;
      double pnl = m_pos.Profit() + m_pos.Swap();
      if(profitableOnly && pnl <= 0) continue;
      if(!profitableOnly && pnl >= 0) continue;
      if(!honourAccountScope && !IsOathraOwned(m_pos.Ticket(), m_pos.Magic())) { skipped++; continue; }

      ulong  posId = (ulong)PositionGetInteger(POSITION_IDENTIFIER);  // before close
      string sym   = m_pos.Symbol();
      double vol   = m_pos.Volume();

      m_trade.SetTypeFilling(GetFillingMode(sym));
      bool ok = m_trade.PositionClose(m_pos.Ticket());
      if(ok) closed++;

      double closedLots = m_trade.ResultVolume();
      if(ok && closedLots <= 0) closedLots = vol;
      if(!ok) closedLots = 0.0;
      AppendPositionResult(out, posId, sym, vol, closedLots, m_trade.ResultPrice(), (int)m_trade.ResultRetcode());
   }
   out.message = (skipped > 0)
      ? StringFormat("Closed %d %s positions; %d skipped (not owned by Oathra)",
                     closed, profitableOnly ? "profitable" : "losing", skipped)
      : StringFormat("Closed %d %s positions", closed,
                     profitableOnly ? "profitable" : "losing");
   return closed > 0;
}

//+------------------------------------------------------------------+
// Honors MagicFilter on top of the explicit magic arg: if the user has
// set MagicFilter, the requested magic must match it (otherwise the
// remote command is fighting the user's own filter). This refusal
// applies whether or not honourAccountScope is set — an account-scope
// close still respects the terminal's own sync-side magic filter here,
// exactly as CmdCloseByMagic always has.
bool CmdCloseByMagic(const int magic, const bool honourAccountScope, CmdResult &out)
{
   if(MagicFilter != 0 && magic != MagicFilter)
   {
      out.message = StringFormat("Refused: requested magic %d does not match MagicFilter %d",
                               magic, MagicFilter);
      return false;
   }

   out.scope_honoured = honourAccountScope;
   int closed = 0, skipped = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      if(!m_pos.SelectByIndex(i)) continue;
      if(m_pos.Magic() != magic) continue;
      if(!honourAccountScope && !IsOathraOwned(m_pos.Ticket(), m_pos.Magic())) { skipped++; continue; }

      ulong  posId = (ulong)PositionGetInteger(POSITION_IDENTIFIER);  // before close
      string sym   = m_pos.Symbol();
      double vol   = m_pos.Volume();

      m_trade.SetTypeFilling(GetFillingMode(sym));
      bool ok = m_trade.PositionClose(m_pos.Ticket());
      if(ok) closed++;

      double closedLots = m_trade.ResultVolume();
      if(ok && closedLots <= 0) closedLots = vol;
      if(!ok) closedLots = 0.0;
      AppendPositionResult(out, posId, sym, vol, closedLots, m_trade.ResultPrice(), (int)m_trade.ResultRetcode());
   }
   out.message = (skipped > 0)
      ? StringFormat("Closed %d positions with magic %d; %d skipped (not owned by Oathra)",
                     closed, magic, skipped)
      : StringFormat("Closed %d positions with magic %d", closed, magic);
   return closed > 0;
}

//+------------------------------------------------------------------+
bool CmdModifySLTP(ulong ticket, double sl, double tp, string &resultMsg)
{
   if(!IsTicketCloseable(ticket, resultMsg)) return false;  // Pass 1 Sol 1
   string sym = PositionGetString(POSITION_SYMBOL);
   double entry = PositionGetDouble(POSITION_PRICE_OPEN);
   ValidateStopLevels(sym, entry, sl, tp);
   int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
   sl = NormalizeDouble(sl, digits);
   tp = NormalizeDouble(tp, digits);
   bool ok = m_trade.PositionModify(ticket, sl, tp);
   resultMsg = ok ? StringFormat("Modified ticket %d SL=%.5f TP=%.5f", ticket, sl, tp)
                  : "Modify failed";
   return ok;
}

//+------------------------------------------------------------------+
bool CmdMoveSLBreakEven(double offsetPips, string &resultMsg)
{
   // offset_pips must be >= 0 (negative inverts the BE semantics).
   // The caller (move_sl_be_plus) should additionally ensure > 0 but
   // the EA guard fires on < 0 only — plus-check is enforced monitor-side.
   if(offsetPips < 0)
   {
      resultMsg = "offset_pips must be >= 0";
      return false;
   }

   int moved = 0, skipped = 0;
   for(int i = PositionsTotal() - 1; i >= 0; i--)
   {
      if(!m_pos.SelectByIndex(i)) continue;
      if(!IsOathraOwned(m_pos.Ticket(), m_pos.Magic())) { skipped++; continue; }
      double entry  = m_pos.PriceOpen();
      // Pass 1 Sol 7: use GetNetPnl (profit+swap) so positions with negative
      // carry cost don't get their SL moved to BE when economic net is < 0.
      // Consistent with the trailing-stop peak-PnL logic (line ~675).
      double netPnl = GetNetPnl(m_pos.Ticket());
      if(netPnl <= 0) continue;

      string sym     = m_pos.Symbol();
      double pipSize = GetPipSize(sym);
      double offset  = offsetPips * pipSize;
      double newSL   = (m_pos.PositionType() == POSITION_TYPE_BUY)
                       ? entry + offset : entry - offset;

      int digits = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
      newSL = NormalizeDouble(newSL, digits);

      if(m_trade.PositionModify(m_pos.Ticket(), newSL, m_pos.TakeProfit()))
         moved++;
   }
   resultMsg = (skipped > 0)
      ? StringFormat("Moved SL to BE%s on %d positions; %d skipped (not owned by Oathra)",
                     offsetPips > 0 ? StringFormat("+%.1f pips", offsetPips) : "", moved, skipped)
      : StringFormat("Moved SL to BE%s on %d positions",
                     offsetPips > 0 ? StringFormat("+%.1f pips", offsetPips) : "", moved);
   return moved > 0;
}

//+------------------------------------------------------------------+
bool CmdStartTrailing(ulong ticket, string method, string payload, string &resultMsg)
{
   // MC08 / MT-05 / C2b: capture the identifier before IsTicketCloseable
   // rewrites `ticket` to the live POSITION_TICKET. g_Trail[] is keyed
   // on the identifier (every earlier trailing_start call stored it the
   // same way), which is the value this ticket keeps for life — use the
   // live ticket only where the position is actually selected already.
   ulong identifier = ticket;
   if(!IsTicketCloseable(ticket, resultMsg)) return false;  // Pass 1 Sol 1

   int methodIdx = -1;
   if(method == "points")          methodIdx = 0;
   else if(method == "atr")        methodIdx = 1;
   else if(method == "profit_pct") methodIdx = 2;
   else if(method == "be_plus")    methodIdx = 3;
   else { resultMsg = "Unknown trailing method: " + method; return false; }

   // Read param1 by method, not first-nonzero. The old chain silently
   // accepted a `points` call with `distance:0` by falling through to
   // whichever of atr_period / profit_pct / offset_pips happened to be
   // populated next.
   double param1 = 0;
   switch(methodIdx)
   {
      case 0: param1 = GetFieldNum(payload, "distance");    break; // points
      case 1: param1 = GetFieldNum(payload, "atr_period");  break; // atr
      case 2: param1 = GetFieldNum(payload, "profit_pct");  break; // profit_pct
      case 3: param1 = GetFieldNum(payload, "offset_pips"); break; // be_plus (offset can be 0)
   }
   if(methodIdx != 3 && param1 <= 0)
   {
      resultMsg = StringFormat("Trailing %s requires a positive distance/period/pct", method);
      return false;
   }
   double param2 = GetFieldNum(payload, "atr_multiplier");
   if(param2 == 0) param2 = 1.5;

   // Pass 2 Sol 3: dedup-then-append — remove any existing trailing config for
   // this ticket before inserting the new one. Without this, duplicate calls
   // (retry, double-click, re-open drawer) append competing configs that
   // oscillate the SL every tick. Mirrors the CmdSetTPLevels replace pattern.
   int writeIdx = 0;
   for(int i = 0; i < g_TrailCount; i++)
   {
      if(g_Trail[i].ticket == identifier) continue; // drop old config for this ticket
      if(writeIdx != i) g_Trail[writeIdx] = g_Trail[i];
      writeIdx++;
   }
   g_TrailCount = writeIdx;

   if(g_TrailCount >= MAX_TRAILING)
   {
      resultMsg = "Max trailing configs reached";
      return false;
   }

   g_Trail[g_TrailCount].ticket     = identifier;
   g_Trail[g_TrailCount].method     = methodIdx;
   g_Trail[g_TrailCount].param1     = param1;
   g_Trail[g_TrailCount].param2     = param2;
   g_Trail[g_TrailCount].highestPnl = 0;
   g_Trail[g_TrailCount].activated  = false;
   g_Trail[g_TrailCount].direction  = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? 1 : -1;
   g_Trail[g_TrailCount].symbol     = PositionGetString(POSITION_SYMBOL);
   g_Trail[g_TrailCount].step_points = MathMax(0.0, GetFieldNum(payload, "step_points"));
   g_TrailCount++;

   resultMsg = StringFormat("Trailing %s started on ticket %d (replaced any existing config)", method, identifier);
   SaveTMState();
   return true;
}

//+------------------------------------------------------------------+
bool CmdSetTPLevels(ulong ticket, string payload, string &resultMsg)
{
   // MC08 / MT-05 / C2b: capture the identifier before IsTicketCloseable
   // rewrites `ticket` to the live POSITION_TICKET. g_TP[] is keyed on
   // the identifier (every earlier set_tp_levels call stored it the same
   // way), which is the value this position keeps for life.
   ulong identifier = ticket;
   if(!IsTicketCloseable(ticket, resultMsg)) return false;  // Pass 1 Sol 1

   int    dir   = (PositionGetInteger(POSITION_TYPE) == POSITION_TYPE_BUY) ? 1 : -1;
   double entry = PositionGetDouble(POSITION_PRICE_OPEN);
   // Capture the broker SL now, while `ticket` is still the selected
   // position. CleanupTracking() below calls PositionSelectByTicket on
   // other tracked tickets, so reading POSITION_SL after it would return
   // a foreign position's stop — which then gets stamped onto `ticket`.
   double currentSL = PositionGetDouble(POSITION_SL);
   // v4.26 (B54): a move to break-even lands be_offset_pips in the trade's
   // favour, so a stop at entry does not close the trade for a loss after
   // commission and spread. Read while `ticket` is still selected.
   string posSymbol    = PositionGetString(POSITION_SYMBOL);
   double pipSize      = GetPipSize(posSymbol);
   int    posDigits    = (int)SymbolInfoInteger(posSymbol, SYMBOL_DIGITS);
   double beOffsetPips = MathMax(0.0, GetFieldNum(payload, "be_offset_pips"));

   // Parse tp1..tpN dynamically. The header advertises MAX_TP_LEVELS=150;
   // older versions hardcoded only tp1/tp2/tp3 so any tp4..tpN sent by
   // the app was silently dropped. Sparse keys (tp1+tp3 with no tp2)
   // are allowed — we scan the full range and skip absent entries.
   double tpPrices  [];  ArrayResize(tpPrices,   MAX_TP_LEVELS);
   double tpPcts    [];  ArrayResize(tpPcts,     MAX_TP_LEVELS);
   double tpSlMoves [];  ArrayResize(tpSlMoves,  MAX_TP_LEVELS);
   bool   tpPctGiven[];  ArrayResize(tpPctGiven, MAX_TP_LEVELS);
   int    parsedCount = 0;

   for(int n = 1; n <= MAX_TP_LEVELS && parsedCount < MAX_TP_LEVELS; n++)
   {
      string keyP   = StringFormat("tp%d",         n);
      string keyPct = StringFormat("tp%d_pct",     n);
      string keySL  = StringFormat("move_sl_on_tp%d", n);
      double tp = GetFieldNum(payload, keyP);
      if(tp <= 0) continue;
      if((dir > 0 && tp <= entry) || (dir < 0 && tp >= entry))
      {
         resultMsg = StringFormat("TP%d %.5f on wrong side of entry %.5f for %s",
                                  n, tp, entry, dir > 0 ? "BUY" : "SELL");
         return false;
      }
      tpPrices  [parsedCount] = tp;
      tpPctGiven[parsedCount] = HasField(payload, keyPct);
      tpPcts    [parsedCount] = GetFieldNum(payload, keyPct);
      double slMove = GetFieldNum(payload, keySL);
      if(slMove > 0 && beOffsetPips > 0 && MathAbs(slMove - entry) < pipSize / 2)
         slMove = NormalizeDouble(entry + dir * beOffsetPips * pipSize, posDigits);
      tpSlMoves [parsedCount] = slMove;
      parsedCount++;
   }

   if(parsedCount == 0)
   {
      resultMsg = "No valid TP levels provided";
      return false;
   }

   // Auto-balance percentages so the supplied levels sum to 100%.
   // Levels with explicit tpN_pct keep their value; levels without one
   // share the remainder evenly. Old behavior was 50/30/20 hardcoded
   // (sum 100%) but only if all three were present; partial inputs
   // would leave 20%-50% unmanaged.
   double pctSum    = 0;
   int    pctMissing = 0;
   for(int i = 0; i < parsedCount; i++)
   {
      if(tpPctGiven[i]) pctSum += tpPcts[i];
      else              pctMissing++;
   }
   if(pctSum > 100.0)
   {
      resultMsg = StringFormat("TP percentages sum to %.1f%% (must be <= 100%%)", pctSum);
      return false;
   }
   if(pctMissing > 0)
   {
      double remainder = 100.0 - pctSum;
      double share     = remainder / pctMissing;
      for(int i = 0; i < parsedCount; i++)
         if(!tpPctGiven[i]) tpPcts[i] = share;
   }

   // Drop existing TP-tracking entries for this ticket (after validation
   // so we don't leave the position half-armed on a parse error).
   // Compact immediately so freed slots are reusable in the add loop.
   for(int i = 0; i < g_TPCount; i++)
      if(g_TP[i].ticket == identifier) g_TP[i].triggered = true;
   CleanupTracking();

   int added = 0;
   for(int i = 0; i < parsedCount && g_TPCount < MAX_TP_LEVELS; i++)
   {
      g_TP[g_TPCount].ticket     = identifier;
      g_TP[g_TPCount].tp_price   = tpPrices[i];
      g_TP[g_TPCount].close_pct  = tpPcts[i];
      g_TP[g_TPCount].move_sl_to = tpSlMoves[i];
      g_TP[g_TPCount].triggered  = false;
      g_TP[g_TPCount].direction  = dir;
      g_TPCount++;
      added++;
   }

   if(added == 0)
   {
      resultMsg = "TP capacity full (MAX_TP_LEVELS reached)";
      return false;
   }

   // EA now owns the TP. Clear the broker-side TP so the broker doesn't
   // race the EA's multi-level logic. If the modify fails (e.g. requote,
   // freeze level), roll back the in-memory levels to keep the broker
   // and EA in sync — safer than tracking levels behind a still-set TP.
   // currentSL was captured at the top, before CleanupTracking moved the
   // implicit position selection.
   if(!m_trade.PositionModify(ticket, currentSL, 0))
   {
      uint retcode = m_trade.ResultRetcode();
      for(int i = 0; i < g_TPCount; i++)
         if(g_TP[i].ticket == identifier) g_TP[i].triggered = true;
      resultMsg = StringFormat("PositionModify failed (retcode %d) — TP setup rolled back",
                               retcode);
      // Pass 1 Sol 18: persist rolled-back state so EA reload sees triggered
      // levels and doesn't re-attempt the old TPs from disk.
      SaveTMState();
      return false;
   }

   resultMsg = StringFormat("Set %d TP levels on ticket %d", added, identifier);
   SaveTMState();
   return true;
}

//==================================================================
//  Broker symbol catalog upload (v4.21)
//
//  Three-step chunked upload protocol so a 1500-5000 symbol catalog
//  (200-800 KB JSON) can stream up safely without exceeding monitor
//  body-size limits or socket timeouts:
//
//    1. /sync/symbols/begin   — server issues upload_id + ack hash
//    2. /sync/symbols/chunk   — client posts batches of CHUNK_SIZE
//                               symbols, each with full spec + sessions
//    3. /sync/symbols/commit  — server stitches, verifies hash, swaps
//                               truth atomically
//
//  Idempotent: server-issued upload_id, replay-safe commit. On any
//  failure mid-stream, the EA abandons and Railway will re-issue
//  dump_symbols on the next /sync/account if hash still mismatches.
//==================================================================

#define SYMBOLS_CHUNK_SIZE  200

string ExtractUploadId(const string response)
{
   string token = "\"upload_id\":\"";
   int pos = StringFind(response, token);
   if(pos < 0) return "";
   pos += StringLen(token);
   int end = StringFind(response, "\"", pos);
   if(end <= pos) return "";
   return StringSubstr(response, pos, end - pos);
}

// Fixture-side struct shape for the pure, exported BuildSymbolJson below.
// A04-C10's own contract (SerializerGolden.mq5's convention, §CONTRACT):
// tools/mt5/tests/SymbolJsonGolden.mq5 declares an identical local copy —
// same field names, same types — and #includes the carved region, so a
// mismatch is a compile error there, never a silent drift. Sessions are
// carried as two pre-built JSON array strings (never SymbolInfoSession*
// calls, which are broker-dependent) so the pure builder stays a plain
// string assembler with no engine lookups of its own.
struct SymbolSpec
{
   string name;
   string description;
   string path;
   int    digits;
   double point;
   double tick_size;
   double tick_value;
   double volume_min;
   double volume_step;
   double volume_max;
   double volume_limit;
   double contract_size;
   int    stops_level;
   int    freeze_level;
   int    filling_mode;
   int    expiration_mode;
   int    trade_mode;
   string currency_base;
   string currency_profit;
   string currency_margin;
   int    swap_mode;
   double swap_long;
   double swap_short;
   int    swap_rollover_day;
   double margin_initial;
   double margin_maintenance;
   // v4.27 broker facts. A double is NaN when the terminal did not report it; trade_calc_mode and spread_points
   // are LONG_MIN when not reported; spread_float is -1 not reported, 0 false, 1 true; any NaN in swap_days
   // sends the whole array as null.
   double tick_value_profit;
   double tick_value_loss;
   long   trade_calc_mode;
   double margin_hedged;
   long   spread_points;
   int    spread_float;
   double swap_days[7];
   string sessions_quote_json;  // pre-built JSON array text, e.g. "[]" or "[{...}]"
   string sessions_trade_json;
};

// @export-begin BuildSymbolJson
// Pure: formats a possibly-invalid broker double as a JSON number, or
// `null` when the value is NaN/Inf (L6: missing money data is "not
// reported", never a literal 0). A raw NaN handed to DoubleToString does
// not print as a JSON number token at all — that is the actual cause of
// some of the 85 live /chunk failures this chunk investigates (see build
// log), since the server then fails to JSON.parse the chunk body outright
// rather than rejecting one field. swap_long/swap_short are the two
// fields a real broker has been seen to report this way (Islamic /
// swap-free symbols); every other double below is left as plain
// DoubleToString on purpose — widening the guard to fields nothing in
// the 85-failure investigation touched would be an unrequested change.
string JsonNumOrNull(const double v, const int digits)
{
   if(!MathIsValidNumber(v)) return "null";
   return DoubleToString(v, digits);
}

// v4.27: an integer fact the terminal did not report is LONG_MIN in the struct and `null` on the wire.
string JsonLongOrNull(const long v)
{
   if(v == LONG_MIN) return "null";
   return IntegerToString(v);
}

// v4.27: -1 = not reported, 0 = false, 1 = true.
string JsonBoolOrNull(const int v)
{
   if(v < 0) return "null";
   return (v == 0) ? "false" : "true";
}

// v4.27: the seven swap multipliers Sunday to Saturday, or `null` when any of them is not a valid number.
string JsonSwapDaysOrNull(const double &days[])
{
   string out = "[";
   for(int i = 0; i < 7; i++)
   {
      if(!MathIsValidNumber(days[i])) return "null";
      if(i > 0) out += ",";
      out += DoubleToString(days[i], 6);
   }
   return out + "]";
}

// Pure: every value comes from the struct, never a broker/SymbolInfo
// lookup — this is what C51's harness compiles standalone into
// SymbolJsonGolden.mq5 to prove the EA's own bytes produce this shape,
// including the three edge cases that broke live uploads: a description
// carrying quotes and non-ASCII, an empty path, and a NaN swap.
string BuildSymbolJson(const SymbolSpec &s)
{
   string j = "{";
   j += "\"symbol\":\""        + EscapeJson(s.name) + "\",";
   j += "\"description\":\""   + EscapeJson(s.description) + "\",";
   j += "\"path\":\""          + EscapeJson(s.path) + "\",";
   j += "\"digits\":"          + IntegerToString(s.digits) + ",";
   j += "\"point\":"           + DoubleToString(s.point, 10) + ",";
   j += "\"tick_size\":"       + DoubleToString(s.tick_size, 10) + ",";
   j += "\"tick_value\":"      + DoubleToString(s.tick_value, 10) + ",";
   j += "\"volume_min\":"      + DoubleToString(s.volume_min, 6) + ",";
   j += "\"volume_step\":"     + DoubleToString(s.volume_step, 6) + ",";
   j += "\"volume_max\":"      + DoubleToString(s.volume_max, 2) + ",";
   j += "\"volume_limit\":"    + DoubleToString(s.volume_limit, 2) + ",";
   j += "\"contract_size\":"   + DoubleToString(s.contract_size, 6) + ",";
   j += "\"stops_level\":"     + IntegerToString(s.stops_level) + ",";
   j += "\"freeze_level\":"    + IntegerToString(s.freeze_level) + ",";
   j += "\"filling_mode\":"    + IntegerToString(s.filling_mode) + ",";
   j += "\"expiration_mode\":" + IntegerToString(s.expiration_mode) + ",";
   j += "\"trade_mode\":"      + IntegerToString(s.trade_mode) + ",";
   j += "\"currency_base\":\""    + EscapeJson(s.currency_base) + "\",";
   j += "\"currency_profit\":\""  + EscapeJson(s.currency_profit) + "\",";
   j += "\"currency_margin\":\""  + EscapeJson(s.currency_margin) + "\",";
   j += "\"swap_mode\":"       + IntegerToString(s.swap_mode) + ",";
   j += "\"swap_long\":"       + JsonNumOrNull(s.swap_long, 6)  + ",";
   j += "\"swap_short\":"      + JsonNumOrNull(s.swap_short, 6) + ",";
   j += "\"swap_rollover_day\":" + IntegerToString(s.swap_rollover_day) + ",";
   j += "\"margin_initial\":"     + DoubleToString(s.margin_initial, 6) + ",";
   j += "\"margin_maintenance\":" + DoubleToString(s.margin_maintenance, 6) + ",";
   j += "\"tick_value_profit\":" + JsonNumOrNull(s.tick_value_profit, 10) + ",";
   j += "\"tick_value_loss\":"   + JsonNumOrNull(s.tick_value_loss, 10) + ",";
   j += "\"trade_calc_mode\":"   + JsonLongOrNull(s.trade_calc_mode) + ",";
   j += "\"margin_hedged\":"     + JsonNumOrNull(s.margin_hedged, 6) + ",";
   j += "\"spread_points\":"     + JsonLongOrNull(s.spread_points) + ",";
   j += "\"spread_float\":"      + JsonBoolOrNull(s.spread_float) + ",";
   j += "\"swap_days\":"         + JsonSwapDaysOrNull(s.swap_days) + ",";
   j += "\"sessions_quote\":" + s.sessions_quote_json + ",";
   j += "\"sessions_trade\":" + s.sessions_trade_json;
   j += "}";
   return j;
}
// @export-end

// A NaN the collector uses for a fact the terminal did not report (v4.27). The golden script builds its own
// NaN from 0.0 / zeroDivisor; this one is only ever called here, outside the exported regions.
double NotReportedDouble()
{
   return MathSqrt(-1.0);
}

// Collects the broker-dependent values (SymbolInfo*/SymbolInfoSession*
// lookups — deliberately NOT part of the export region above, which must
// never call broker functions itself) into a SymbolSpec and hands it to
// the pure BuildSymbolJson for serialization. All fields tolerate the
// broker returning 0/false from SymbolInfoDouble — server treats null and
// 0 distinctly via the column nullability so 0 stays meaningful.
string BuildSymbolJson(const string sym)
{
   SymbolSpec s;
   s.name               = sym;
   s.description         = SymbolInfoString(sym, SYMBOL_DESCRIPTION);
   s.path                = SymbolInfoString(sym, SYMBOL_PATH);
   s.digits              = (int)SymbolInfoInteger(sym, SYMBOL_DIGITS);
   s.point                = SymbolInfoDouble(sym, SYMBOL_POINT);
   s.tick_size             = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_SIZE);
   s.tick_value            = SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE);
   s.volume_min            = SymbolInfoDouble(sym, SYMBOL_VOLUME_MIN);
   s.volume_step           = SymbolInfoDouble(sym, SYMBOL_VOLUME_STEP);
   s.volume_max            = SymbolInfoDouble(sym, SYMBOL_VOLUME_MAX);
   s.volume_limit          = SymbolInfoDouble(sym, SYMBOL_VOLUME_LIMIT);
   s.contract_size         = SymbolInfoDouble(sym, SYMBOL_TRADE_CONTRACT_SIZE);
   s.stops_level           = (int)SymbolInfoInteger(sym, SYMBOL_TRADE_STOPS_LEVEL);
   s.freeze_level          = (int)SymbolInfoInteger(sym, SYMBOL_TRADE_FREEZE_LEVEL);
   s.filling_mode          = (int)SymbolInfoInteger(sym, SYMBOL_FILLING_MODE);
   s.expiration_mode       = (int)SymbolInfoInteger(sym, SYMBOL_EXPIRATION_MODE);
   s.trade_mode            = (int)SymbolInfoInteger(sym, SYMBOL_TRADE_MODE);
   s.currency_base         = SymbolInfoString(sym, SYMBOL_CURRENCY_BASE);
   s.currency_profit       = SymbolInfoString(sym, SYMBOL_CURRENCY_PROFIT);
   s.currency_margin       = SymbolInfoString(sym, SYMBOL_CURRENCY_MARGIN);
   s.swap_mode              = (int)SymbolInfoInteger(sym, SYMBOL_SWAP_MODE);
   s.swap_long               = SymbolInfoDouble(sym, SYMBOL_SWAP_LONG);
   s.swap_short              = SymbolInfoDouble(sym, SYMBOL_SWAP_SHORT);
   s.swap_rollover_day       = (int)SymbolInfoInteger(sym, SYMBOL_SWAP_ROLLOVER3DAYS);
   s.margin_initial          = SymbolInfoDouble(sym, SYMBOL_MARGIN_INITIAL);
   s.margin_maintenance      = SymbolInfoDouble(sym, SYMBOL_MARGIN_MAINTENANCE);

   // v4.27 broker facts, each through the checked overload: a read the terminal refuses is NOT REPORTED, never 0.
   // The 26 reads above are left as they were (changing them would move stored values of every existing catalog).
   // A tick value of 0 is what the terminal answers for a symbol it has not loaded, so it is not reported either.
   double d = 0.0;
   long   l = 0;
   s.tick_value_profit = (SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE_PROFIT, d) && d > 0.0) ? d : NotReportedDouble();
   s.tick_value_loss   = (SymbolInfoDouble(sym, SYMBOL_TRADE_TICK_VALUE_LOSS, d) && d > 0.0) ? d : NotReportedDouble();
   s.margin_hedged     = SymbolInfoDouble(sym, SYMBOL_MARGIN_HEDGED, d) ? d : NotReportedDouble();
   s.trade_calc_mode   = SymbolInfoInteger(sym, SYMBOL_TRADE_CALC_MODE, l) ? l : LONG_MIN;
   s.spread_points     = SymbolInfoInteger(sym, SYMBOL_SPREAD, l) ? l : LONG_MIN;
   s.spread_float      = SymbolInfoInteger(sym, SYMBOL_SPREAD_FLOAT, l) ? ((l != 0) ? 1 : 0) : -1;
   s.swap_days[0]      = SymbolInfoDouble(sym, SYMBOL_SWAP_SUNDAY, d)    ? d : NotReportedDouble();
   s.swap_days[1]      = SymbolInfoDouble(sym, SYMBOL_SWAP_MONDAY, d)    ? d : NotReportedDouble();
   s.swap_days[2]      = SymbolInfoDouble(sym, SYMBOL_SWAP_TUESDAY, d)   ? d : NotReportedDouble();
   s.swap_days[3]      = SymbolInfoDouble(sym, SYMBOL_SWAP_WEDNESDAY, d) ? d : NotReportedDouble();
   s.swap_days[4]      = SymbolInfoDouble(sym, SYMBOL_SWAP_THURSDAY, d)  ? d : NotReportedDouble();
   s.swap_days[5]      = SymbolInfoDouble(sym, SYMBOL_SWAP_FRIDAY, d)    ? d : NotReportedDouble();
   s.swap_days[6]      = SymbolInfoDouble(sym, SYMBOL_SWAP_SATURDAY, d)  ? d : NotReportedDouble();

   // Sessions: days 0-6 × idx 0-3 × kind {quote, trade}. SymbolInfoSession*
   // returns false when no session exists at that slot — skip cleanly.
   datetime fromTime = 0, toTime = 0;
   string quoteArr = "", tradeArr = "";

   for(int day = 0; day < 7; day++)
   {
      for(int idx = 0; idx < 4; idx++)
      {
         if(SymbolInfoSessionQuote(sym, (ENUM_DAY_OF_WEEK)day, idx, fromTime, toTime))
         {
            if(StringLen(quoteArr) > 0) quoteArr += ",";
            quoteArr += StringFormat("{\"day\":%d,\"idx\":%d,\"from\":%I64u,\"to\":%I64u}",
                                     day, idx, (long)fromTime, (long)toTime);
         }
         if(SymbolInfoSessionTrade(sym, (ENUM_DAY_OF_WEEK)day, idx, fromTime, toTime))
         {
            if(StringLen(tradeArr) > 0) tradeArr += ",";
            tradeArr += StringFormat("{\"day\":%d,\"idx\":%d,\"from\":%I64u,\"to\":%I64u}",
                                     day, idx, (long)fromTime, (long)toTime);
         }
      }
   }
   s.sessions_quote_json = "[" + quoteArr + "]";
   s.sessions_trade_json = "[" + tradeArr + "]";

   return BuildSymbolJson(s);
}

bool CmdDumpSymbols(string &resultMsg)
{
   if(StringLen(g_AccountID) == 0)
   {
      resultMsg = "No account_id yet — skipping catalog upload";
      return false;
   }

   // Refresh hash so /commit can verify against a known-good digest.
   g_SymbolsHash = BuildSymbolsHash();
   int total = SymbolsTotal(false);
   g_SymbolsCount = total;

   // 1. /begin — server issues upload_id
   string beginBody = "{";
   beginBody += "\"account_id\":\""              + g_AccountID + "\",";
   beginBody += "\"broker_company\":\""          + EscapeJson(AccountInfoString(ACCOUNT_COMPANY)) + "\",";
   beginBody += "\"server_name\":\""             + EscapeJson(AccountInfoString(ACCOUNT_SERVER))  + "\",";
   beginBody += "\"symbols_hash\":\""            + g_SymbolsHash + "\",";
   beginBody += "\"symbols_count\":"             + IntegerToString(total) + ",";
   beginBody += "\"server_tz_offset_minutes\":"  + IntegerToString((int)((TimeCurrent() - TimeGMT()) / 60)) + ",";
   // v4.27: the raw ACCOUNT_MARGIN_MODE integer; the monitor converts it through the one verified table.
   beginBody += "\"margin_mode\":"               + IntegerToString(AccountInfoInteger(ACCOUNT_MARGIN_MODE));
   beginBody += "}";

   string beginResp = "";
   if(!Post("/sync/symbols/begin", beginBody, beginResp))
   {
      resultMsg = "Catalog upload failed at /begin";
      return false;
   }
   string uploadId = ExtractUploadId(beginResp);
   if(StringLen(uploadId) == 0)
   {
      resultMsg = "Catalog upload: no upload_id returned";
      return false;
   }

   // 2. /chunk — stream symbols in CHUNK_SIZE batches
   int sequence = 0;
   int sent = 0;
   string buffer = "";
   int batchCount = 0;

   for(int i = 0; i < total; i++)
   {
      string sym = SymbolName(i, false);
      if(StringLen(sym) == 0) continue;

      if(StringLen(buffer) > 0) buffer += ",";
      buffer += BuildSymbolJson(sym);
      batchCount++;
      sent++;

      // Yield CPU every 50 symbols so OnTimer isn't starved on big brokers.
      if(sent % 50 == 0) Sleep(1);

      // Flush every CHUNK_SIZE symbols or at end-of-list.
      if(batchCount >= SYMBOLS_CHUNK_SIZE || i == total - 1)
      {
         string chunkBody = "{\"upload_id\":\"" + uploadId + "\","
                          + "\"sequence\":"     + IntegerToString(sequence) + ","
                          + "\"symbols\":["     + buffer + "]}";
         string chunkResp = "";
         if(!Post("/sync/symbols/chunk", chunkBody, chunkResp))
         {
            resultMsg = StringFormat("Catalog upload failed at /chunk seq=%d (sent %d/%d)",
                                     sequence, sent, total);
            return false;
         }
         if(DebugLog)
            Print("Oathra: catalog chunk ", sequence, " sent (", batchCount, " symbols)");
         buffer = "";
         batchCount = 0;
         sequence++;
      }
   }

   // 3. /commit — atomic finalize
   string commitBody = "{\"upload_id\":\"" + uploadId + "\","
                     + "\"total_chunks\":" + IntegerToString(sequence) + "}";
   string commitResp = "";
   if(!Post("/sync/symbols/commit", commitBody, commitResp))
   {
      resultMsg = "Catalog upload failed at /commit";
      return false;
   }

   resultMsg = StringFormat("Catalog uploaded: %d symbols, %d chunks", sent, sequence);
   if(DebugLog) Print("Oathra: ", resultMsg);
   return true;
}

//==================================================================
//  TM helpers
//==================================================================

//+------------------------------------------------------------------+
// Compact triggered TP entries and entries whose position is gone.
void CleanupTracking()
{
   int writeIdx = 0;
   for(int i = 0; i < g_TPCount; i++)
   {
      if(!g_TP[i].triggered)
      {
         if(writeIdx != i) g_TP[writeIdx] = g_TP[i];
         writeIdx++;
      }
   }
   g_TPCount = writeIdx;

   writeIdx = 0;
   for(int i = 0; i < g_TrailCount; i++)
   {
      if(PositionSelectByTicket(g_Trail[i].ticket))
      {
         if(writeIdx != i) g_Trail[writeIdx] = g_Trail[i];
         writeIdx++;
      }
   }
   g_TrailCount = writeIdx;

   // Pass 2 Sol 2: prune ticket history for tickets that are fully gone —
   // no longer live as a position AND no longer live as a pending order.
   // Keeping pending-order tickets matters: IsOrderCloseable relies on this
   // history to authorize delete_pending / modify_pending. Pruning a still-
   // pending order here would lock the user out of their own TM order.
   writeIdx = 0;
   for(int i = 0; i < g_TmOpenedTicketsCount; i++)
   {
      if(PositionSelectByTicket(g_TmOpenedTickets[i])
         || OrderSelect(g_TmOpenedTickets[i]))
      {
         if(writeIdx != i) g_TmOpenedTickets[writeIdx] = g_TmOpenedTickets[i];
         writeIdx++;
      }
   }
   g_TmOpenedTicketsCount = writeIdx;
}

//+------------------------------------------------------------------+
// Push SL/TP outside the broker minimum-distance window.
void ValidateStopLevels(string symbol, double entry, double &sl, double &tp)
{
   long stopsLevelPts  = SymbolInfoInteger(symbol, SYMBOL_TRADE_STOPS_LEVEL);
   long freezeLevelPts = SymbolInfoInteger(symbol, SYMBOL_TRADE_FREEZE_LEVEL);
   double point  = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double minDist = MathMax(stopsLevelPts, freezeLevelPts) * point;
   if(minDist <= 0) minDist = 10 * point;

   if(sl > 0 && MathAbs(entry - sl) < minDist)
      sl = (sl < entry) ? entry - minDist : entry + minDist;
   if(tp > 0 && MathAbs(entry - tp) < minDist)
      tp = (tp > entry) ? entry + minDist : entry - minDist;
}

//+------------------------------------------------------------------+
// Returns 0 when the requested lot size is below broker minimum so the
// caller can skip the operation. Clamping up (the old behavior) silently
// turned a tiny TP partial / split into a full broker-min order.
double NormalizeLots(string symbol, double lots)
{
   double lotStep = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP);
   double lotMin  = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   double lotMax  = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MAX);
   if(lotStep <= 0) lotStep = 0.01;
   if(lots < lotMin) return 0.0;
   double normalized = MathFloor(lots / lotStep) * lotStep;
   normalized = MathMin(lotMax, normalized);
   if(normalized < lotMin) return 0.0;
   // `normalized` is already an exact multiple of lotStep; rounding to 8
   // decimals only clears binary-float dust (e.g. 0.1*3 = 0.30000000004).
   // The old `decimals` came from -log10(lotStep), which mis-rounds a
   // non-power-of-10 step (e.g. 0.25 → NormalizeDouble(...,1) = 0.3, an
   // invalid volume).
   return NormalizeDouble(normalized, 8);
}

//+------------------------------------------------------------------+
ENUM_ORDER_TYPE_FILLING GetFillingMode(string symbol)
{
   long filling = SymbolInfoInteger(symbol, SYMBOL_FILLING_MODE);
   if((filling & SYMBOL_FILLING_FOK) != 0) return ORDER_FILLING_FOK;
   if((filling & SYMBOL_FILLING_IOC) != 0) return ORDER_FILLING_IOC;
   return ORDER_FILLING_RETURN;
}

//==================================================================
//  Risk-meta helpers (used by BuildOpenJSON / BuildClosedJSON)
//==================================================================

bool CalculateRiskAmount(const string symbol, const string direction, double lots,
                         double openPrice, double stopLossPrice, double &riskAmount)
{
   riskAmount = 0.0;
   if(StringLen(symbol) == 0 || lots <= 0 || openPrice <= 0 || stopLossPrice <= 0)
      return false;

   ENUM_ORDER_TYPE orderType = (direction == "sell") ? ORDER_TYPE_SELL : ORDER_TYPE_BUY;
   double potentialLoss = 0.0;
   if(!OrderCalcProfit(orderType, symbol, lots, openPrice, stopLossPrice, potentialLoss))
      return false;

   riskAmount = MathAbs(potentialLoss);
   return riskAmount > 0;
}

void ResolvePositionRiskMeta(ulong positionId, const string symbol, const string direction, double lots,
                             double openPriceFallback, double &initialRiskAmount,
                             bool &hadStopLossAtOpen, datetime &stopLossSetAt)
{
   initialRiskAmount = 0.0;
   hadStopLossAtOpen = false;
   stopLossSetAt = 0;

   if(positionId == 0 || StringLen(symbol) == 0 || lots <= 0)
      return;

   // HistorySelectByPosition doesn't require a prior HistorySelect —
   // it reselects independently. The previous HistorySelect(0, ...)
   // call here was a per-iteration full-history scan, the second leg
   // of the O(N²) reconcile (the first was BuildClosedJSON).
   if(!HistorySelectByPosition(positionId))
      return;

   double openPrice = openPriceFallback;
   datetime earliestOpenTime  = 0;
   datetime earliestOrderTime = 0;
   double   earliestOrderSL   = 0.0;
   datetime firstStopLossTime = 0;

   for(int d = 0; d < HistoryDealsTotal(); d++)
   {
      ulong dealTicket = HistoryDealGetTicket(d);
      if(dealTicket == 0) continue;

      ENUM_DEAL_ENTRY entry = (ENUM_DEAL_ENTRY)HistoryDealGetInteger(dealTicket, DEAL_ENTRY);
      if(entry != DEAL_ENTRY_IN && entry != DEAL_ENTRY_INOUT) continue;

      datetime dealTime = (datetime)HistoryDealGetInteger(dealTicket, DEAL_TIME);
      if(earliestOpenTime == 0 || dealTime < earliestOpenTime)
      {
         earliestOpenTime = dealTime;
         openPrice        = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
      }
   }

   for(int o = 0; o < HistoryOrdersTotal(); o++)
   {
      ulong orderTicket = HistoryOrderGetTicket(o);
      if(orderTicket == 0) continue;
      if((ulong)HistoryOrderGetInteger(orderTicket, ORDER_POSITION_ID) != positionId) continue;

      datetime orderTime = (datetime)HistoryOrderGetInteger(orderTicket, ORDER_TIME_SETUP);
      double   orderSL   = HistoryOrderGetDouble(orderTicket, ORDER_SL);

      if(earliestOrderTime == 0 || orderTime < earliestOrderTime)
      {
         earliestOrderTime = orderTime;
         earliestOrderSL   = orderSL;
      }

      if(orderSL > 0 && (firstStopLossTime == 0 || orderTime < firstStopLossTime))
         firstStopLossTime = orderTime;
   }

   hadStopLossAtOpen = earliestOrderSL > 0;
   stopLossSetAt     = firstStopLossTime;

   if(hadStopLossAtOpen)
      CalculateRiskAmount(symbol, direction, lots, openPrice, earliestOrderSL, initialRiskAmount);
}

//==================================================================
//  JSON helpers
//
//  These walk the string honoring `"..."` boundaries and `\\` / `\"`
//  escapes so that a structural char (`}`, `]`, `,`) inside a string
//  value cannot terminate parsing early. The previous naive StringFind
//  parser silently truncated payloads whose comment fields contained
//  brackets or quotes.
//==================================================================

// Skip past the JSON string starting at `pos` (pointer at the opening
// quote). Returns the index of the closing quote, or -1 if the string
// is unterminated.
int SkipJsonString(const string s, int pos)
{
   int n = StringLen(s);
   if(pos < 0 || pos >= n || StringGetCharacter(s, pos) != '"') return -1;
   for(int i = pos + 1; i < n; i++)
   {
      ushort ch = StringGetCharacter(s, i);
      if(ch == '\\') { i++; continue; }   // skip escaped char
      if(ch == '"')  return i;
   }
   return -1;
}

// Find the offset of `target` (a structural char like `}` or `]`) at
// depth 0 starting from `from`, ignoring contents of string literals.
// -1 if not found.
int FindStructural(const string s, ushort target, int from)
{
   int n = StringLen(s);
   int depth = 0;
   for(int i = from; i < n; i++)
   {
      ushort ch = StringGetCharacter(s, i);
      if(ch == '"')
      {
         int closing = SkipJsonString(s, i);
         if(closing < 0) return -1;
         i = closing;
         continue;
      }
      if(ch == '{' || ch == '[') depth++;
      else if(ch == '}' || ch == ']')
      {
         if(depth == 0 && ch == target) return i;
         depth--;
      }
      else if(depth == 0 && ch == target) return i;
   }
   return -1;
}

// Find a key occurrence (`"key":`) that is a DIRECT CHILD of the
// object/array `json` itself starts with — ignoring matches inside
// string values, matches that are substrings of a longer key (e.g.
// searching `"key"` must not match `"keyer"`), AND matches nested one
// or more levels deeper (e.g. `{"foo":{"key":1}}` must not match "key").
// Returns the offset of the opening `"` of the key, or -1.
//
// REPAIR (05-review-1.md Finding 1, CRITICAL): the previous version had
// no bracket-depth tracking at all — it accepted a `"key":` match
// anywhere in the string, as long as the character immediately before
// it was `{`, `,` or whitespace. That boundary check is satisfied just
// as well by the `{` that opens a NESTED object as by the one that
// opens the top-level object being searched, so `honourAccountScope`
// (line ~1579) read `"scope":"account"` out of
// `{"foo":{"scope":"account"}}` as if it were top-level — reproduced
// with a line-for-line JS port of this exact function against that
// exact payload before this fix (05-review-1.md, this build's REPAIR
// session). `depth` here tracks nesting the same way `FindStructural`
// above already does; a match only counts at depth 1 — one level
// inside the object `json` itself opens with — never deeper.
int FindKey(const string json, const string key)
{
   string token = "\"" + key + "\":";
   int    klen  = StringLen(token);
   int    n     = StringLen(json);
   int    depth = 0;
   for(int i = 0; i < n; i++)
   {
      ushort ch = StringGetCharacter(json, i);
      if(ch == '"')
      {
         if(depth == 1 && i + klen <= n && StringSubstr(json, i, klen) == token)
         {
            // Word boundary: char before `i` must be `{`, `,` or whitespace.
            if(i == 0) return i;
            ushort prev = StringGetCharacter(json, i - 1);
            if(prev == '{' || prev == ',' || prev == ' ' || prev == '\t' ||
               prev == '\n' || prev == '\r')
               return i;
         }
         // Not a key match at this position (wrong depth, wrong boundary,
         // or a different key) — skip past the string body so we don't
         // descend into its contents, and so its own `{`/`}`/`,` chars
         // never perturb the depth count below.
         int closing = SkipJsonString(json, i);
         if(closing < 0) return -1;
         i = closing;
         continue;
      }
      if(ch == '{' || ch == '[') depth++;
      else if(ch == '}' || ch == ']') depth--;
   }
   return -1;
}

bool HasField(const string json, const string key)
{
   return FindKey(json, key) >= 0;
}

string ExtractJsonString(const string json, const string key)
{
   int kpos = FindKey(json, key);
   if(kpos < 0) return "";
   string token = "\"" + key + "\":";
   int    pos   = kpos + StringLen(token);
   int    n     = StringLen(json);
   while(pos < n && StringGetCharacter(json, pos) == ' ') pos++;
   if(pos >= n || StringGetCharacter(json, pos) != '"') return "";
   int closing = SkipJsonString(json, pos);
   if(closing < 0) return "";
   return StringSubstr(json, pos + 1, closing - pos - 1);
}

double ExtractJsonDouble(const string json, const string key)
{
   int kpos = FindKey(json, key);
   if(kpos < 0) return 0;
   string token = "\"" + key + "\":";
   int    pos   = kpos + StringLen(token);
   int    n     = StringLen(json);
   while(pos < n && StringGetCharacter(json, pos) == ' ') pos++;
   if(pos >= n) return 0;

   // Quoted numeric: "key":"value"
   if(StringGetCharacter(json, pos) == '"')
   {
      int closing = SkipJsonString(json, pos);
      if(closing < 0) return 0;
      return StringToDouble(StringSubstr(json, pos + 1, closing - pos - 1));
   }

   // Bare numeric / literal
   string numStr = "";
   for(int ci = pos; ci < n; ci++)
   {
      ushort ch = StringGetCharacter(json, ci);
      if(ch == ',' || ch == '}' || ch == ']' || ch == ' '
         || ch == '\t' || ch == '\n' || ch == '\r') break;
      numStr += CharToString((uchar)ch);
   }
   return StringToDouble(numStr);
}

string ExtractNestedJson(const string json, const string key)
{
   int kpos = FindKey(json, key);
   if(kpos < 0) return "";
   string token = "\"" + key + "\":";
   int    pos   = kpos + StringLen(token);
   int    n     = StringLen(json);
   while(pos < n && StringGetCharacter(json, pos) == ' ') pos++;
   if(pos >= n || StringGetCharacter(json, pos) != '{') return "";
   int end = FindStructural(json, '}', pos + 1);
   if(end < 0) return "";
   return StringSubstr(json, pos, end - pos + 1);
}

// Cross-format field accessor: prefers nested "payload.<key>", falls back
// to top-level "<key>". Presence is checked via HasField so that an
// explicit zero/false/empty value in the payload is honored — the old
// `if(v != 0)` fall-through silently routed `"distance":0` to whichever
// other field happened to be nonzero at the top level.
string GetFieldStr(const string cmdJson, const string key)
{
   string payload = ExtractNestedJson(cmdJson, "payload");
   if(StringLen(payload) > 0 && HasField(payload, key))
      return ExtractJsonString(payload, key);
   return ExtractJsonString(cmdJson, key);
}

double GetFieldNum(const string cmdJson, const string key)
{
   string payload = ExtractNestedJson(cmdJson, "payload");
   if(StringLen(payload) > 0 && HasField(payload, key))
      return ExtractJsonDouble(payload, key);
   return ExtractJsonDouble(cmdJson, key);
}

bool ExtractJsonBool(const string json, const string key)
{
   int kpos = FindKey(json, key);
   if(kpos < 0) return false;
   string token = "\"" + key + "\":";
   int    pos   = kpos + StringLen(token);
   int    n     = StringLen(json);
   while(pos < n && StringGetCharacter(json, pos) == ' ') pos++;
   if(pos + 4 > n) return false;
   return StringSubstr(json, pos, 4) == "true";
}

bool GetFieldBool(const string cmdJson, const string key)
{
   string payload = ExtractNestedJson(cmdJson, "payload");
   if(StringLen(payload) > 0 && HasField(payload, key))
      return ExtractJsonBool(payload, key);
   return ExtractJsonBool(cmdJson, key);
}

// @export-begin EscapeJson
// Pure: plain string manipulation, no broker lookup of any kind — marked
// as its own region (rather than left unmarked) because A04-C10's
// BuildSymbolJson region calls it directly, and extract-regions.mjs
// refuses a region that calls a same-file helper sitting outside every
// region. MQL5 strings are UTF-16 internally and JSON accepts raw,
// unescaped Unicode content in a string literal — only these five
// characters ever need escaping.
string EscapeJson(const string str)
{
   string s = str;
   StringReplace(s, "\\", "\\\\");
   StringReplace(s, "\"", "\\\"");
   StringReplace(s, "\n", "\\n");
   StringReplace(s, "\r", "\\r");
   StringReplace(s, "\t", "\\t");
   return s;
}
// @export-end

// v4.30: the inverse of EscapeJson, for a string value read back out of JSON
// with ExtractJsonString (which returns the raw, still-escaped text).
string UnescapeJson(const string str)
{
   string out = "";
   int    n   = StringLen(str);
   for(int i = 0; i < n; i++)
   {
      ushort ch = StringGetCharacter(str, i);
      if(ch != '\\' || i + 1 >= n)
      {
         out += ShortToString(ch);
         continue;
      }
      ushort nx = StringGetCharacter(str, i + 1);
      if(nx == 'u' && i + 5 < n)
      {
         string hex  = StringSubstr(str, i + 2, 4);
         int    code = 0;
         bool   ok   = true;
         for(int k = 0; k < 4; k++)
         {
            ushort h = StringGetCharacter(hex, k);
            int    v = (h >= '0' && h <= '9') ? h - '0'
                     : (h >= 'a' && h <= 'f') ? h - 'a' + 10
                     : (h >= 'A' && h <= 'F') ? h - 'A' + 10 : -1;
            if(v < 0) { ok = false; break; }
            code = code * 16 + v;
         }
         if(ok)
         {
            out += ShortToString((ushort)code);
            i += 5;
            continue;
         }
      }
      if(nx == 'n')                    out += "\n";
      else if(nx == 'r')               out += "\r";
      else if(nx == 't')               out += "\t";
      else if(nx == 'b')               out += ShortToString(8);
      else if(nx == 'f')               out += ShortToString(12);
      else if(nx == '"' || nx == '\\' || nx == '/') out += ShortToString(nx);
      else                             { out += ShortToString(ch); out += ShortToString(nx); }
      i++;
   }
   return out;
}

// Split a JSON array into its element substrings. Each element is the literal
// JSON of a `{...}` object. Returns count. Walks string boundaries so element
// values containing `]` / `}` don't truncate parsing.
//
// `key` selects the array: a non-empty key locates `"key":[...]`; an empty
// key ("") treats `json` itself as a bare top-level array (`[{...},{...}]`).
int SplitJsonObjectArray(const string json, const string key, string &tokens[])
{
   ArrayResize(tokens, 0);
   int n        = StringLen(json);
   int arrStart = -1;

   if(StringLen(key) == 0)
   {
      // Top-level array — locate the first '['.
      arrStart = StringFind(json, "[");
   }
   else
   {
      int kpos = FindKey(json, key);
      if(kpos < 0) return 0;
      string token = "\"" + key + "\":";
      int    pos   = kpos + StringLen(token);
      while(pos < n && StringGetCharacter(json, pos) == ' ') pos++;
      if(pos < n && StringGetCharacter(json, pos) == '[') arrStart = pos;
   }
   if(arrStart < 0) return 0;

   int arrEnd = FindStructural(json, ']', arrStart + 1);
   if(arrEnd < 0) return 0;

   int contentLen = arrEnd - arrStart - 1;
   if(contentLen < 2) return 0;
   string content = StringSubstr(json, arrStart + 1, contentLen);

   int count = 0, searchFrom = 0;
   int cn = StringLen(content);
   while(searchFrom < cn)
   {
      int objStart = -1;
      for(int i = searchFrom; i < cn; i++)
      {
         ushort ch = StringGetCharacter(content, i);
         if(ch == '"') { int cc = SkipJsonString(content, i); if(cc < 0) return count; i = cc; continue; }
         if(ch == '{') { objStart = i; break; }
      }
      if(objStart < 0) break;
      int objEnd = FindStructural(content, '}', objStart + 1);
      if(objEnd < 0) break;
      ArrayResize(tokens, count + 1);
      tokens[count] = StringSubstr(content, objStart, objEnd - objStart + 1);
      count++;
      searchFrom = objEnd + 1;
   }
   return count;
}

//==================================================================
//  TM state persistence (issue #10)
//
//  multi-TP, trailing, and trading-lock all live in RAM. Without this,
//  any EA reload (recompile, chart change, MT5 restart) silently drops
//  the user's risk-management — the position keeps trading but TP1/TP2/
//  TP3 + trailing stop-out are no longer being watched.
//==================================================================

string TMStateFileName()
{
   long login = AccountInfoInteger(ACCOUNT_LOGIN);
   return "oathra_tm_state_" + IntegerToString(login) + ".json";
}

void SaveTMState()
{
   string fname = TMStateFileName();
   // Switched to FILE_UNICODE (UTF-16) so symbols with non-ASCII chars
   // (some Russian/Asian broker symbol suffixes) round-trip cleanly.
   // The "version" field lets a future format change be back-compatible.
   int h = FileOpen(fname, FILE_WRITE | FILE_TXT | FILE_UNICODE);
   if(h == INVALID_HANDLE)
   {
      if(DebugLog) Print("Oathra TM: SaveTMState FileOpen failed (", GetLastError(), ")");
      return;
   }

   string j = "{\"version\":2,\"locked\":";
   j += g_TradingLocked ? "true" : "false";
   j += ",\"tp\":[";
   for(int i = 0; i < g_TPCount; i++)
   {
      if(i > 0) j += ",";
      j += StringFormat("{\"ticket\":%I64u,\"price\":%.5f,\"pct\":%.2f,\"sl_to\":%.5f,\"trig\":%s,\"dir\":%d}",
            g_TP[i].ticket, g_TP[i].tp_price, g_TP[i].close_pct, g_TP[i].move_sl_to,
            g_TP[i].triggered ? "true" : "false", g_TP[i].direction);
   }
   j += "],\"trail\":[";
   for(int i = 0; i < g_TrailCount; i++)
   {
      if(i > 0) j += ",";
      j += StringFormat("{\"ticket\":%I64u,\"method\":%d,\"p1\":%.5f,\"p2\":%.5f,\"hi\":%.2f,\"act\":%s,\"dir\":%d,\"sym\":\"%s\",\"step\":%.1f}",
            g_Trail[i].ticket, g_Trail[i].method, g_Trail[i].param1, g_Trail[i].param2,
            g_Trail[i].highestPnl, g_Trail[i].activated ? "true" : "false",
            g_Trail[i].direction, EscapeJson(g_Trail[i].symbol), g_Trail[i].step_points);
   }
   // Pass 2 Sol 2: persist TM-opened ticket history across EA reloads.
   j += "],\"tmtickets\":[";
   for(int i = 0; i < g_TmOpenedTicketsCount; i++)
   {
      if(i > 0) j += ",";
      j += IntegerToString(g_TmOpenedTickets[i]);
   }
   j += "]}";

   FileWriteString(h, j);
   FileClose(h);
}

void LoadTMState()
{
   string fname = TMStateFileName();
   if(!FileIsExist(fname)) return;

   // Try UTF-16 first (current format). If the file was written by a
   // pre-4.20 EA (FILE_ANSI), the read returns garbage / empty — fall
   // back to ANSI and rewrite as v2 on next save.
   int h = FileOpen(fname, FILE_READ | FILE_TXT | FILE_UNICODE);
   string j = "";
   if(h != INVALID_HANDLE)
   {
      while(!FileIsEnding(h)) j += FileReadString(h);
      FileClose(h);
   }
   if(StringLen(j) < 2 || StringFind(j, "\"locked\"") < 0)
   {
      // Legacy v1 ANSI format
      h = FileOpen(fname, FILE_READ | FILE_TXT | FILE_ANSI);
      if(h == INVALID_HANDLE)
      {
         if(DebugLog) Print("Oathra TM: LoadTMState FileOpen failed (", GetLastError(), ")");
         return;
      }
      j = "";
      while(!FileIsEnding(h)) j += FileReadString(h);
      FileClose(h);
   }
   if(StringLen(j) < 2) return;

   g_TradingLocked = ExtractJsonBool(j, "locked");

   string tpTokens[];
   int    tpN  = SplitJsonObjectArray(j, "tp", tpTokens);
   int    droppedTP = 0;
   for(int i = 0; i < tpN && g_TPCount < MAX_TP_LEVELS; i++)
   {
      ulong tk = (ulong)ExtractJsonDouble(tpTokens[i], "ticket");
      if(tk == 0 || !PositionSelectByTicket(tk)) { droppedTP++; continue; }
      g_TP[g_TPCount].ticket     = tk;
      g_TP[g_TPCount].tp_price   = ExtractJsonDouble(tpTokens[i], "price");
      g_TP[g_TPCount].close_pct  = ExtractJsonDouble(tpTokens[i], "pct");
      g_TP[g_TPCount].move_sl_to = ExtractJsonDouble(tpTokens[i], "sl_to");
      g_TP[g_TPCount].triggered  = ExtractJsonBool  (tpTokens[i], "trig");
      g_TP[g_TPCount].direction  = (int)ExtractJsonDouble(tpTokens[i], "dir");
      g_TPCount++;
   }

   string trTokens[];
   int    trN = SplitJsonObjectArray(j, "trail", trTokens);
   int    droppedTr = 0;
   for(int i = 0; i < trN && g_TrailCount < MAX_TRAILING; i++)
   {
      ulong tk = (ulong)ExtractJsonDouble(trTokens[i], "ticket");
      if(tk == 0 || !PositionSelectByTicket(tk)) { droppedTr++; continue; }
      g_Trail[g_TrailCount].ticket     = tk;
      g_Trail[g_TrailCount].method     = (int)ExtractJsonDouble(trTokens[i], "method");
      g_Trail[g_TrailCount].param1     = ExtractJsonDouble(trTokens[i], "p1");
      g_Trail[g_TrailCount].param2     = ExtractJsonDouble(trTokens[i], "p2");
      g_Trail[g_TrailCount].highestPnl = ExtractJsonDouble(trTokens[i], "hi");
      g_Trail[g_TrailCount].activated  = ExtractJsonBool  (trTokens[i], "act");
      g_Trail[g_TrailCount].direction  = (int)ExtractJsonDouble(trTokens[i], "dir");
      g_Trail[g_TrailCount].symbol     = UnescapeJson(ExtractJsonString(trTokens[i], "sym")); // v4.30: saved through EscapeJson
      // Absent in a pre-4.26 state file: reads 0, the old any-move behaviour.
      g_Trail[g_TrailCount].step_points = ExtractJsonDouble(trTokens[i], "step");
      g_TrailCount++;
   }

   // Pass 2 Sol 2: restore TM-opened ticket history.
   // Format is a plain JSON number array: "tmtickets":[12345,12346,...]
   // Parse by finding the bracket range then splitting on commas.
   {
      string tkKey = "\"tmtickets\":[";
      int tkStart  = StringFind(j, tkKey);
      if(tkStart >= 0)
      {
         tkStart += StringLen(tkKey);
         int tkEnd = StringFind(j, "]", tkStart);
         if(tkEnd > tkStart)
         {
            string tkContent = StringSubstr(j, tkStart, tkEnd - tkStart);
            string parts[];
            int    nParts = StringSplit(tkContent, ',', parts);
            for(int i = 0; i < nParts && g_TmOpenedTicketsCount < MAX_TM_TICKETS; i++)
            {
               string s = parts[i];
               StringTrimLeft(s); StringTrimRight(s);
               ulong tk = (ulong)StringToInteger(s);
               if(tk > 0) TrackTmTicket(tk);
            }
         }
      }
   }

   if(g_TPCount > 0 || g_TrailCount > 0 || g_TradingLocked)
      Print("Oathra TM: restored from disk — ", g_TPCount, " TP / ",
            g_TrailCount, " trail / locked=", g_TradingLocked ? "yes" : "no",
            " / ", g_TmOpenedTicketsCount, " ticket history",
            " (dropped ", droppedTP + droppedTr, " stale entries)");

   // If anything was dropped, rewrite the file so we don't keep loading
   // dead tickets every restart.
   if(droppedTP + droppedTr > 0)
   {
      SaveTMState();
      // Tell the user once so the silent-failure pattern is broken.
      SendNotification(StringFormat("Oathra TM: %d tracked positions were closed while EA was offline — TP/trailing dropped",
                                    droppedTP + droppedTr));
   }
}

//+------------------------------------------------------------------+
// Pip size — handles 5-digit forex (0.00010), 3-digit JPY (0.010),
// 2-digit metals/indices (0.01, 1.0). For symbols whose tick size
// differs from point size (some crypto/index pricing), trust the
// tick size — sl_pips/tp_pips converted via GetPipSize then becomes
// "1 tick = 1 pip" instead of "1 point = 1 pip", which matches user
// intent for those instruments.
double GetPipSize(const string symbol)
{
   int    digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   double point  = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double tick   = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   if(digits == 5 || digits == 3) return point * 10;
   if(tick > 0 && tick > point)   return tick;
   return point;
}

//==================================================================
//  HTTP helpers
//==================================================================

// Backoff schedule (seconds): 0, 30, 60, 120, 300, 600, 600, 600, …
ulong HttpBackoffMs(int failures)
{
   if(failures <= 0) return 0;
   if(failures == 1) return 30000;
   if(failures == 2) return 60000;
   if(failures == 3) return 120000;
   if(failures == 4) return 300000;
   return 600000;
}

void HttpRecordFailure(int httpCode)
{
   g_HttpFailures++;
   g_HttpNextRetryMs = GetTickCount64() + HttpBackoffMs(g_HttpFailures);
}

void HttpRecordSuccess()
{
   if(g_HttpFailures > 0 || g_HttpUrlMissing || g_HttpOathRevoked)
   {
      Print("Oathra: HTTP recovered after ", g_HttpFailures, " consecutive failures");
   }
   g_HttpFailures    = 0;
   g_HttpNextRetryMs = 0;
   g_HttpUrlMissing  = false;
   // 423 oath-revoked auto-clears on success: a successful sync proves the
   // user re-took the oath, so we should resume normal operation.
   g_HttpOathRevoked = false;
   g_HttpOathRetryMs = 0;
   // v4.30: a 401 clears on success too. Until 4.29 it stopped all sync until
   // the EA was restarted, so one wrong 401 from the monitor (2026-10-15)
   // froze a live account for a day while the terminal showed it connected.
   g_Http401Count    = 0;
   g_HttpAuthBroken  = false;
}

bool HttpReady()
{
   // 423 oath_required: pause for 30 min so the user can re-take the oath
   // in the Oathra app without the EA spamming the journal with retries.
   // After the cooldown a single attempt is allowed; success clears the flag,
   // another 423 re-arms it.
   if(g_HttpOathRevoked && GetTickCount64() < g_HttpOathRetryMs) return false;
   if(g_HttpNextRetryMs > 0 && GetTickCount64() < g_HttpNextRetryMs) return false;
   return true;
}

// Surface the two HTTP-error classes that need user action: stale API
// key (401) and URL not in MT5's allow list (WebRequest returns -1 with
// last-error 4014/4060/etc on Windows). Each fires Alert() at most once
// per session so the user sees a clear next step.
void HttpSurfaceFatal(int httpCode, int lastErr, const string endpoint)
{
   // v4.30: a 401 rides the normal backoff (30 s, 60 s, 2 min, 5 min, then
   // every 10 min) and sync resumes on its own at the next success. The
   // Alert waits for the third 401 in a row, so a single wrong refusal never
   // tells the user to rotate a key that is fine.
   if(httpCode == 401)
   {
      g_Http401Count++;
      if(g_Http401Count == 1)
         Print("Oathra: 401 on ", endpoint, ". The server refused the API key. Retrying.");
      if(g_Http401Count >= HTTP_401_ALERT_AFTER && !g_HttpAuthBroken)
      {
         g_HttpAuthBroken = true;
         Print("Oathra: the API key was refused ", g_Http401Count, " times in a row. Still retrying every few minutes.");
         Alert("Oathra: API key invalid. Open Oathra app → Settings → Data & Sync, rotate, paste new key, re-attach EA.");
      }
      return;
   }
   // 423 Locked = monitor's requireAccountLinkOath middleware rejecting the
   // request because the user revoked their Data & Metrics Oath. Pause sync
   // for 30 min and surface a single, actionable Alert. Cleared on next 200.
   if(httpCode == 423)
   {
      g_HttpOathRetryMs = GetTickCount64() + 30UL * 60UL * 1000UL;
      if(!g_HttpOathRevoked)
      {
         g_HttpOathRevoked = true;
         Print("Oathra: 423 on ", endpoint,
               " — Data & Metrics Oath revoked or outdated. Sync paused for 30 min.");
         Alert("Oathra: Data link paused. Re-take the Data & Metrics Oath in the Oathra app to resume sync.");
      }
      return;
   }
   if(httpCode < 0 && !g_HttpUrlMissing)
   {
      g_HttpUrlMissing = true;
      Print("Oathra: WebRequest blocked on ", endpoint, " (last error ", lastErr,
            "). Add ", OathraURL, " to MT5 → Tools → Options → Expert Advisors → Allow WebRequest.");
      Alert("Oathra: WebRequest blocked. Add ", OathraURL, " to the MT5 allow list.");
   }
}

//+------------------------------------------------------------------+
// v4.28: one slot per /sync endpoint that got another 4xx. -1 when the
// endpoint has none and add is false.
int SyncEndpointSlot(const string endpoint, const bool add)
{
   int n = ArraySize(g_EpBlockName);
   for(int i = 0; i < n; i++)
      if(g_EpBlockName[i] == endpoint) return i;
   if(!add) return -1;
   ArrayResize(g_EpBlockName, n + 1);
   ArrayResize(g_EpBlockUntilMs, n + 1);
   ArrayResize(g_EpBlockFailures, n + 1);
   g_EpBlockName[n]     = endpoint;
   g_EpBlockUntilMs[n]  = 0;
   g_EpBlockFailures[n] = 0;
   return n;
}

//+------------------------------------------------------------------+
bool Post(const string endpoint, const string body, string &response)
{
   response = "";
   g_LastHttpCode = 0;
   if(!HttpReady()) return false;

   // v4.28: /sync/* can be paused on its own (limiter 429, 426) while
   // /commands keeps polling, so Guardian closes and Sync now still arrive.
   // A repeating 4xx pauses only the endpoint that got it.
   bool isSync = StringFind(endpoint, "/sync/") == 0;
   if(isSync && g_SyncBlockedUntilMs > 0 && GetTickCount64() < g_SyncBlockedUntilMs) return false;
   int epSlot = isSync ? SyncEndpointSlot(endpoint, false) : -1;
   if(epSlot >= 0 && GetTickCount64() < g_EpBlockUntilMs[epSlot]) return false;

   char   reqData[];
   char   resData[];
   string resHeaders;
   string headers = "Content-Type: application/json\r\nAuthorization: Bearer " + APIKey;

   // StringToCharArray appends a null terminator. Some servers reject the
   // trailing \0 — exclude it by sizing the buffer to StringLen(body).
   int bodyLen = StringLen(body);
   ArrayResize(reqData, bodyLen);
   StringToCharArray(body, reqData, 0, bodyLen);

   ArrayResize(resData, 0);
   ResetLastError();
   int httpCode = WebRequest("POST", OathraURL + endpoint, headers, 5000,
                             reqData, resData, resHeaders);
   int lastErr  = GetLastError();
   g_LastHttpCode = httpCode;

   if(httpCode < 0)
   {
      HttpSurfaceFatal(httpCode, lastErr, endpoint);
      HttpRecordFailure(httpCode);
      if(DebugLog) Print("Oathra WebRequest error on ", endpoint,
                         " | Error: ", lastErr,
                         " | failures=", g_HttpFailures);
      return false;
   }

   // Use actual response length so a smaller-than-buffer reply doesn't
   // bring along garbage from a previous request.
   int respLen = ArraySize(resData);
   response = (respLen > 0) ? CharArrayToString(resData, 0, respLen, CP_UTF8) : "";

   // Accept any 2xx — /sync/symbols/commit returns 202 Accepted, and 204
   // No Content is valid for empty-body responses.
   if(httpCode >= 200 && httpCode < 300)
   {
      HttpRecordSuccess();
      if(isSync) g_SyncBlockedUntilMs = 0;
      if(epSlot >= 0)
      {
         g_EpBlockFailures[epSlot] = 0;
         g_EpBlockUntilMs[epSlot]  = 0;
      }
      if(DebugLog) Print("Oathra ", endpoint, " → HTTP ", httpCode, " | ", response);
      return true;
   }

   if(DebugLog) Print("Oathra HTTP ", httpCode, " on ", endpoint, " | ", response);

   // v4.28: only the network (above) and the server failing arm the global
   // backoff, which also stops the command poll.
   if(httpCode >= 500)
   {
      HttpSurfaceFatal(httpCode, lastErr, endpoint);
      HttpRecordFailure(httpCode);
      return false;
   }
   // 401 (key) and 423 (oath) keep their own pauses, as before.
   if(httpCode == 401 || httpCode == 423)
   {
      HttpSurfaceFatal(httpCode, lastErr, endpoint);
      HttpRecordFailure(httpCode);
      return false;
   }
   if(httpCode == 429)
   {
      // The limiter pauses /sync for its window; a cadence 429 goes back to
      // SyncAccount, which holds the next heartbeat for retry_after_ms.
      if(ExtractJsonString(response, "code") == "sync_rate_limited")
      {
         double retryMs = ExtractJsonDouble(response, "retry_after_ms");
         if(retryMs < 1000)  retryMs = 1000;
         if(retryMs > 60000) retryMs = 60000;
         g_SyncBlockedUntilMs = GetTickCount64() + (ulong)retryMs;
         Print("Oathra: sync paused ", (int)(retryMs / 1000), "s by the server's rate limit");
      }
      return false;
   }
   if(httpCode == 426)
   {
      g_SyncBlockedUntilMs = GetTickCount64() + 600000;
      if(!g_Http426Alerted)
      {
         g_Http426Alerted = true;
         Print("Oathra: 426 on ", endpoint, ". This EA version is too old. Sync paused until it is updated.");
         Alert("Oathra: update the EA. Download the latest version from the Oathra app, then attach it again.");
      }
      return false;
   }
   // Any other 4xx answers this request only. On /sync it pauses that
   // endpoint alone, escalating (30 s up to 10 min) so a refusal that
   // repeats every cycle does not hammer the monitor, and one refused
   // batch never stops the heartbeat; that endpoint's 2xx clears it.
   if(isSync)
   {
      if(epSlot < 0) epSlot = SyncEndpointSlot(endpoint, true);
      g_EpBlockFailures[epSlot]++;
      g_EpBlockUntilMs[epSlot] = GetTickCount64() + HttpBackoffMs(g_EpBlockFailures[epSlot]);
   }
   return false;
}

//+------------------------------------------------------------------+
string Get(const string endpoint)
{
   if(!HttpReady()) return "";

   char   reqData[];
   char   resData[];
   string resHeaders;
   string headers = "Content-Type: application/json\r\nAuthorization: Bearer " + APIKey;

   ArrayResize(reqData, 0);
   ArrayResize(resData, 0);
   ResetLastError();
   int httpCode = WebRequest("GET", OathraURL + endpoint, headers, 3000,
                             reqData, resData, resHeaders);
   int lastErr  = GetLastError();

   if(httpCode < 0)
   {
      HttpSurfaceFatal(httpCode, lastErr, endpoint);
      HttpRecordFailure(httpCode);
      if(DebugLog) Print("Oathra GET error on ", endpoint,
                         " | Error: ", lastErr,
                         " | failures=", g_HttpFailures);
      return "";
   }
   // Accept any 2xx — /sync/symbols/commit returns 202 Accepted, and 204
   // No Content is valid for empty-body responses.
   if(httpCode < 200 || httpCode >= 300)
   {
      HttpSurfaceFatal(httpCode, lastErr, endpoint);
      HttpRecordFailure(httpCode);
      if(DebugLog) Print("Oathra GET HTTP ", httpCode, " on ", endpoint);
      return "";
   }
   HttpRecordSuccess();
   int respLen = ArraySize(resData);
   return (respLen > 0) ? CharArrayToString(resData, 0, respLen, CP_UTF8) : "";
}
//+------------------------------------------------------------------+
