Setup & Access Guide

Everything you need to invite a teammate and connect MT5 for live trade updates.
1. Invite a user 2. Set up MT5 live sync 3. Code files

1 Invite a user to access the dashboard and website

  1. Open your Cloudflare Pages project (or wherever the site is hosted) and confirm the deployment URL you want to share — this is the link your teammate will use to reach the dashboard.
  2. Decide on an access model:
    • Shared login: share the existing dashboard sign-in credentials directly with the person you're inviting.
    • Separate Supabase user: create a new row in your Supabase auth.users table (or use Supabase Authentication → Users → Invite user) so the teammate has their own login.
  3. If using Supabase Authentication:
    1. Log into the Supabase dashboard and open your project.
    2. Go to Authentication → Users.
    3. Click Invite user and enter their email address.
    4. They'll receive an email with a link to set their password and log in.
  4. Send them the dashboard URL plus their login credentials (or invite link).
  5. Once they log in, they'll see the same dashboard, calendar, and trade data as you — there's currently one shared data set, so anyone with access can view and log trades.
If you want a teammate to have view-only access or restricted permissions, that requires adding role-based access control in Supabase (row-level security policies) — let your developer know if you need that set up.

2 Setting up MT5 with live trade updates

Live trade updates work by having a small bridge running alongside your MetaTrader 5 terminal, which pushes your open positions and account data to Supabase in real time. The dashboard then reads that data automatically.

There are two supported approaches:

Option A: MT5 Expert Advisor (EA)

  1. Copy TitanTradersLiveSync.mq5 (see Step 3 below for the full code) into your MT5 MQL5/Experts folder (in MT5: File → Open Data Folder → MQL5 → Experts).
  2. Open MetaEditor, open the file, and press F7 to compile.
  3. In MT5 Tools → Options → Expert Advisors, check Allow WebRequest for listed URL and add your Supabase URL (e.g. https://sbnkfngrkblwsjboqdwx.supabase.co).
  4. Drag the compiled EA onto any chart (it watches all open positions on the account, not just one symbol). Make sure Allow Algo Trading is enabled.
  5. In the EA's Inputs tab, fill in InpServiceRoleKey (Supabase Dashboard → Project Settings → API → service_role secret key) and InpUserId (Supabase Dashboard → Authentication → Users → your account's UUID).
  6. The EA will now push your open positions and realized/unrealized P&L to Supabase automatically, and create draft trade rows you can complete from the app.

Option B: Python bridge script

  1. Make sure Python 3.10+ is installed on the Windows PC running your MT5 terminal.
  2. Install dependencies:
    pip install MetaTrader5 requests
  3. Save mt5_bridge.py (see Step 3 below for the full code) and fill in SUPABASE_SERVICE_ROLE_KEY and USER_ID at the top of the file.
  4. Run the script:
    python mt5_bridge.py
  5. Leave it running in the background while you trade. Stop it with Ctrl+C when you're done for the day.

Before using either option, run supabase_migration_mt5.sql (Step 3 below) once in the Supabase SQL Editor — it adds the live_positions table and draft columns that both options depend on.

Keep your Supabase service role key private — it has full read/write access to your database. Never share it publicly or commit it to a public repository. It should only live in the EA settings or the local mt5_bridge.py config, not in the website's public files.

Verifying it's working

  1. Open a trade in MT5 (or use a demo account).
  2. Within a few seconds, refresh the dashboard — you should see the open position appear under Live Positions / the unrealized P&L stat card.
  3. Close the trade in MT5 and confirm the dashboard's realized P&L updates and the position disappears from the open list.

3 Code files (copy-ready)

These are the exact files referenced above. Copy each one into place as described in Step 2.

mt5_bridge.py
Python bridge script (Option B). Save this as mt5_bridge.py on the PC running MT5.
"""
TitanTraders <-> MT5 live bridge
=============================

Run this on the SAME Windows PC where your MetaTrader 5 terminal is logged
into your broker. While it runs, it will:

  1. Push every currently OPEN position to Supabase (table `live_positions`)
     every few seconds, including live floating (unrealized) P/L.
  2. Watch your trade history for partial closes and add their booked
     profit to that position's `realized_pnl` while it's still open.
  3. When a position is FULLY closed, delete it from `live_positions` and
     insert a DRAFT row into `trades` with only lot size / SL(pips) /
     TP(pips) filled in. Everything else is left empty for you to fill in
     and save from the TitanTraders app yourself.

Only run this while you are actually trading -- close the window (Ctrl+C)
when you're done for the day.

--------------------------------------------------------------------------
SETUP
--------------------------------------------------------------------------
1. On the trading PC, install Python 3.10+ from python.org.
2. Open Command Prompt / PowerShell and run:
       pip install MetaTrader5 requests
3. Open MetaTrader 5 and log into your account (leave it running).
4. Fill in the CONFIG block below:
     - SUPABASE_URL is already correct (same project as the web app).
     - SUPABASE_SERVICE_ROLE_KEY: Supabase Dashboard -> Project Settings
       -> API -> "service_role" secret key. NEVER put this key in the
       website code -- it bypasses all security rules, which is exactly
       why this script (running only on your own PC) can use it to write
       data on your behalf.
     - USER_ID: Supabase Dashboard -> Authentication -> Users -> copy the
       UUID for your TitanTraders login (the Google account you sign in with).
5. Run:
       python mt5_bridge.py
   Leave the window open while you trade. Stop it with Ctrl+C.

Before first use, make sure you've run supabase_migration_mt5.sql once in
the Supabase SQL Editor (adds the live_positions table + draft columns).
--------------------------------------------------------------------------
"""

import time
import datetime
import requests
import MetaTrader5 as mt5

# ============================== CONFIG ===================================
SUPABASE_URL = "https://sbnkfngrkblwsjboqdwx.supabase.co"
SUPABASE_SERVICE_ROLE_KEY = "PASTE_YOUR_SERVICE_ROLE_KEY_HERE"
USER_ID = "PASTE_YOUR_SUPABASE_AUTH_USER_UUID_HERE"

POLL_SECONDS = 3
# ===========================================================================

HEADERS = {
    "apikey": SUPABASE_SERVICE_ROLE_KEY,
    "Authorization": "Bearer " + SUPABASE_SERVICE_ROLE_KEY,
    "Content-Type": "application/json",
}

REST_URL = SUPABASE_URL.rstrip("/") + "/rest/v1"

# ticket -> tracked state dict
tracked = {}
last_deal_ticket_seen = 0


def iso(dt):
    return dt.strftime("%Y-%m-%dT%H:%M:%S") + "Z"


def now_utc():
    return datetime.datetime.utcnow()


def pip_size(symbol):
    info = mt5.symbol_info(symbol)
    if info is None:
        return 0.0001
    point = info.point or 0.0001
    digits = info.digits
    return point * 10 if digits in (3, 5) else point


def upsert_live_position(pos, orig_volume, realized_so_far):
    payload = [{
        "ticket": pos.ticket,
        "user_id": USER_ID,
        "symbol": pos.symbol,
        "direction": "buy" if pos.type == mt5.POSITION_TYPE_BUY else "sell",
        "volume": pos.volume,
        "original_volume": orig_volume,
        "open_price": pos.price_open,
        "current_price": pos.price_current,
        "sl": pos.sl,
        "tp": pos.tp,
        "open_time": iso(datetime.datetime.utcfromtimestamp(pos.time)),
        "unrealized_pnl": pos.profit,
        "realized_pnl": realized_so_far,
        "status": "open",
        "updated_at": iso(now_utc()),
    }]
    r = requests.post(
        REST_URL + "/live_positions?on_conflict=ticket",
        headers={**HEADERS, "Prefer": "resolution=merge-duplicates"},
        json=payload,
        timeout=10,
    )
    if r.status_code >= 300:
        print("[upsert_live_position] error", r.status_code, r.text)


def delete_live_position(ticket):
    r = requests.delete(
        REST_URL + "/live_positions?ticket=eq." + str(ticket),
        headers=HEADERS,
        timeout=10,
    )
    if r.status_code >= 300:
        print("[delete_live_position] error", r.status_code, r.text)


def insert_draft_trade(info, total_realized):
    sl_pips = ""
    tp_pips = ""
    try:
        ps = pip_size(info["symbol"])
        if info["sl"]:
            sl_pips = str(round(abs(info["open_price"] - info["sl"]) / ps, 1))
        if info["tp"]:
            tp_pips = str(round(abs(info["tp"] - info["open_price"]) / ps, 1))
    except Exception as e:
        print("[pip calc] warning:", e)

    row = {
        "user_id": USER_ID,
        "trade_date": info["open_time"].date().isoformat(),
        "lot_size": str(info["original_volume"]),
        "sl_pips": sl_pips,
        "tp_pips": tp_pips,
        "is_draft": True,
        "mt5_ticket": info["ticket"],
        "mt5_symbol": info["symbol"],
    }
    r = requests.post(
        REST_URL + "/trades",
        headers={**HEADERS, "Prefer": "return=representation,resolution=ignore-duplicates"},
        json=[row],
        timeout=10,
    )
    if r.status_code >= 300:
        print("[insert_draft_trade] error", r.status_code, r.text)
    else:
        print("Draft trade created for closed position #%s (%s), realized P/L %.2f" %
              (info["ticket"], info["symbol"], total_realized))


def poll_once():
    global last_deal_ticket_seen

    positions = mt5.positions_get() or []
    open_tickets = set()

    for pos in positions:
        open_tickets.add(pos.ticket)
        if pos.ticket not in tracked:
            tracked[pos.ticket] = {
                "ticket": pos.ticket,
                "symbol": pos.symbol,
                "original_volume": pos.volume,
                "open_price": pos.price_open,
                "sl": pos.sl,
                "tp": pos.tp,
                "open_time": datetime.datetime.utcfromtimestamp(pos.time),
                "realized": 0.0,
            }
        upsert_live_position(pos, tracked[pos.ticket]["original_volume"], tracked[pos.ticket]["realized"])

    # Look back a couple of days for OUT deals to catch realized/partial profit.
    since = now_utc() - datetime.timedelta(days=2)
    deals = mt5.history_deals_get(since, now_utc() + datetime.timedelta(minutes=5)) or []
    for d in deals:
        if d.ticket <= last_deal_ticket_seen:
            continue
        if d.entry in (mt5.DEAL_ENTRY_OUT, mt5.DEAL_ENTRY_OUT_BY):
            pid = d.position_id
            if pid in tracked:
                tracked[pid]["realized"] += (d.profit + d.swap + d.commission)
    if deals:
        last_deal_ticket_seen = max(last_deal_ticket_seen, max(d.ticket for d in deals))

    # Anything we were tracking that's no longer open has fully closed.
    for ticket in list(tracked.keys()):
        if ticket not in open_tickets:
            info = tracked.pop(ticket)
            insert_draft_trade(info, info["realized"])
            delete_live_position(ticket)


def main():
    if USER_ID.startswith("PASTE_") or SUPABASE_SERVICE_ROLE_KEY.startswith("PASTE_"):
        print("Please fill in USER_ID and SUPABASE_SERVICE_ROLE_KEY at the top of this file first.")
        return

    if not mt5.initialize():
        print("MT5 initialize() failed:", mt5.last_error())
        return

    print("Connected to MT5. Watching positions every %ss. Press Ctrl+C to stop." % POLL_SECONDS)
    try:
        while True:
            try:
                poll_once()
            except Exception as e:
                print("[poll_once] error:", e)
            time.sleep(POLL_SECONDS)
    except KeyboardInterrupt:
        print("Stopping...")
    finally:
        mt5.shutdown()


if __name__ == "__main__":
    main()
TitanTradersLiveSync.mq5
MT5 Expert Advisor (Option A). Save this as TitanTradersLiveSync.mq5 in your MT5 MQL5/Experts folder, then compile in MetaEditor.
//+------------------------------------------------------------------+
//| TitanTradersLiveSync.mq5                                              |
//| Runs INSIDE MetaTrader 5 (no external script needed).             |
//| Pushes your open positions + realized/unrealized P/L straight to  |
//| Supabase so the TitanTraders website Live Positions widget updates    |
//| in real time. When a position fully closes, it creates a DRAFT    |
//| row in your "trades" table with lot size / SL(pips) / TP(pips)    |
//| filled in -- everything else stays blank for you to complete.     |
//|                                                                    |
//| SETUP (one time):                                                  |
//| 1) Copy this file into your MT5 "MQL5/Experts" folder              |
//|    (File > Open Data Folder > MQL5 > Experts).                    |
//| 2) In MT5: Tools > Options > Expert Advisors > check "Allow        |
//|    WebRequest for listed URL" and add your Supabase URL, e.g.     |
//|    https://sbnkfngrkblwsjboqdwx.supabase.co                        |
//| 3) Open MetaEditor, open this file, press F7 to compile.          |
//| 4) Drag the compiled EA onto any chart (symbol doesn't matter --  |
//|    it watches ALL open positions on the account, not just one     |
//|    symbol). Make sure "Allow Algo Trading" is enabled.            |
//| 5) In the EA's Inputs tab, fill in:                                |
//|      InpServiceRoleKey -> Supabase Dashboard > Project Settings   |
//|                           > API > "service_role" secret key       |
//|      InpUserId         -> Supabase Dashboard > Authentication >   |
//|                           Users > your account's UUID             |
//| Run supabase_migration_mt5.sql once in the Supabase SQL editor    |
//| before using this (adds the live_positions table + draft columns).|
//+------------------------------------------------------------------+
#property strict
#property version   "1.04"

input group "Supabase connection"
input string InpSupabaseUrl     = "https://sbnkfngrkblwsjboqdwx.supabase.co";
input string InpServiceRoleKey  = ""; // paste your service_role key here
input string InpUserId          = ""; // paste your Supabase auth user UUID here
input int    InpPollSeconds     = 3;

string g_restUrl;

// Parallel arrays tracking each open position we know about.
long     g_tickets[];
double   g_origVolume[];
double   g_openPrice[];
double   g_sl[];
double   g_tp[];
datetime g_openTime[];
string   g_symbol[];
long     g_posType[]; // POSITION_TYPE_BUY or POSITION_TYPE_SELL

//+------------------------------------------------------------------+
int OnInit()
{
   g_restUrl = InpSupabaseUrl + "/rest/v1";
   ArrayResize(g_tickets, 0);
   ArrayResize(g_origVolume, 0);
   ArrayResize(g_openPrice, 0);
   ArrayResize(g_sl, 0);
   ArrayResize(g_tp, 0);
   ArrayResize(g_openTime, 0);
   ArrayResize(g_symbol, 0);
   ArrayResize(g_posType, 0);

   if(StringLen(InpServiceRoleKey) == 0 || StringLen(InpUserId) == 0)
      Print("TitanTradersLiveSync: please fill in InpServiceRoleKey and InpUserId in the EA inputs.");

   EventSetTimer(MathMax(1, InpPollSeconds));
   Print("TitanTradersLiveSync v1.04 started. Polling every ", InpPollSeconds, "s. ",
         "If you don't see this exact version line after recompiling, MT5 is still ",
         "running an old build -- remove the EA from the chart and drag it back on.");
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
   EventKillTimer();
}

//+------------------------------------------------------------------+
void OnTimer()
{
   if(StringLen(InpServiceRoleKey) == 0 || StringLen(InpUserId) == 0) return;
   PollOnce();
}

//+------------------------------------------------------------------+
int FindTracked(long ticket)
{
   for(int i = 0; i < ArraySize(g_tickets); i++)
      if(g_tickets[i] == ticket) return i;
   return -1;
}

//+------------------------------------------------------------------+
void AddTrackedFromCurrent(ulong ticket)
{
   int n = ArraySize(g_tickets);
   ArrayResize(g_tickets, n + 1);
   ArrayResize(g_origVolume, n + 1);
   ArrayResize(g_openPrice, n + 1);
   ArrayResize(g_sl, n + 1);
   ArrayResize(g_tp, n + 1);
   ArrayResize(g_openTime, n + 1);
   ArrayResize(g_symbol, n + 1);
   ArrayResize(g_posType, n + 1);

   g_tickets[n]    = (long)ticket;
   g_origVolume[n] = PositionGetDouble(POSITION_VOLUME);
   g_openPrice[n]  = PositionGetDouble(POSITION_PRICE_OPEN);
   g_sl[n]         = PositionGetDouble(POSITION_SL);
   g_tp[n]         = PositionGetDouble(POSITION_TP);
   g_openTime[n]   = (datetime)PositionGetInteger(POSITION_TIME);
   g_symbol[n]     = PositionGetString(POSITION_SYMBOL);
   g_posType[n]    = PositionGetInteger(POSITION_TYPE);
}

//+------------------------------------------------------------------+
double ComputeRealizedForPosition(ulong ticket)
{
   double realized = 0;
   if(!HistorySelectByPosition((long)ticket)) return 0;
   int total = HistoryDealsTotal();
   for(int i = 0; i < total; i++)
   {
      ulong dealTicket = HistoryDealGetTicket(i);
      long entry = HistoryDealGetInteger(dealTicket, DEAL_ENTRY);
      if(entry == DEAL_ENTRY_OUT || entry == DEAL_ENTRY_OUT_BY)
      {
         realized += HistoryDealGetDouble(dealTicket, DEAL_PROFIT)
                   + HistoryDealGetDouble(dealTicket, DEAL_SWAP)
                   + HistoryDealGetDouble(dealTicket, DEAL_COMMISSION);
      }
   }
   return realized;
}

//+------------------------------------------------------------------+
// Volume-weighted average fill price across every closing deal for this
// position (covers multiple partial closes ending at different prices).
double ComputeAvgClosePriceForPosition(ulong ticket)
{
   double weightedSum = 0, volSum = 0;
   if(!HistorySelectByPosition((long)ticket)) return 0;
   int total = HistoryDealsTotal();
   for(int i = 0; i < total; i++)
   {
      ulong dealTicket = HistoryDealGetTicket(i);
      long entry = HistoryDealGetInteger(dealTicket, DEAL_ENTRY);
      if(entry == DEAL_ENTRY_OUT || entry == DEAL_ENTRY_OUT_BY)
      {
         double vol = HistoryDealGetDouble(dealTicket, DEAL_VOLUME);
         double price = HistoryDealGetDouble(dealTicket, DEAL_PRICE);
         weightedSum += vol * price;
         volSum += vol;
      }
   }
   if(volSum <= 0) return 0;
   return weightedSum / volSum;
}

//+------------------------------------------------------------------+
string ToIso8601(datetime dt)
{
   MqlDateTime t;
   TimeToStruct(dt, t);
   return StringFormat("%04d-%02d-%02dT%02d:%02d:%02dZ", t.year, t.mon, t.day, t.hour, t.min, t.sec);
}

string ToDateOnly(datetime dt)
{
   MqlDateTime t;
   TimeToStruct(dt, t);
   return StringFormat("%04d-%02d-%02d", t.year, t.mon, t.day);
}

double PipSizeOf(string symbol)
{
   int digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);
   double point = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double ps;

   // Metals (Gold/Silver) are NOT standard forex pairs -- their "pip" is a
   // fixed dollar convention (Gold = $0.10 move), independent of how many
   // fractional digits the broker happens to quote the price with. Deriving
   // pip size from SYMBOL_DIGITS/SYMBOL_POINT (like we do for forex below)
   // breaks the moment a broker adds extra quote precision -- e.g. this
   // broker reports XAUUSD.x with SYMBOL_DIGITS=3 / SYMBOL_POINT=0.001,
   // which the old forex-style logic misread as a 10-point pip (0.01)
   // instead of the correct fixed 0.1 (a 10x undercount -> 10x wrong $ risk).
   if(StringFind(symbol, "XAU") >= 0)
      ps = 0.1;   // Gold: 1 pip = $0.10, always -- regardless of quote precision.
   else if(StringFind(symbol, "XAG") >= 0)
      ps = 0.01;  // Silver: 1 pip = $0.01, always -- regardless of quote precision.
   else
      // Standard forex: 5-digit and 3-digit (JPY) quotes carry one extra
      // fractional digit beyond the traditional pip, so 1 pip = 10 points.
      ps = (digits == 3 || digits == 5) ? point * 10 : point;

   // One-time diagnostic per symbol so we can see exactly what MT5 reports
   // for this broker/symbol instead of guessing -- check the Experts tab.
   static string loggedSymbols = "";
   if(StringFind(loggedSymbols, "|" + symbol + "|") < 0)
   {
      loggedSymbols += "|" + symbol + "|";
      Print("TitanTradersLiveSync PIP DEBUG symbol=", symbol,
            " SYMBOL_DIGITS=", digits,
            " SYMBOL_POINT=", DoubleToString(point, 8),
            " computed pipSize=", DoubleToString(ps, 8));
   }
   return ps;
}

//+------------------------------------------------------------------+
int DoWebRequest(string method, string url, string jsonBody, string extraHeaders, string &responseOut)
{
   string headers = "apikey: " + InpServiceRoleKey + "\r\n" +
                    "Authorization: Bearer " + InpServiceRoleKey + "\r\n" +
                    "Content-Type: application/json\r\n" + extraHeaders;

   uchar data[];
   StringToCharArray(jsonBody, data, 0, -1, CP_UTF8);
   // Strip any trailing NUL byte(s) so we don't send a corrupted/truncated body.
   while(ArraySize(data) > 0 && data[ArraySize(data) - 1] == 0)
      ArrayResize(data, ArraySize(data) - 1);

   uchar resultData[];
   string resultHeaders;
   ResetLastError();
   int status = WebRequest(method, url, headers, 5000, data, resultData, resultHeaders);
   responseOut = CharArrayToString(resultData, 0, WHOLE_ARRAY, CP_UTF8);
   if(status == -1)
   {
      int err = GetLastError();
      if(err == 4060)
         Print("TitanTradersLiveSync: WebRequest blocked. Add ", InpSupabaseUrl,
               " under Tools > Options > Expert Advisors > Allow WebRequest for listed URL, then reattach the EA.");
      else
         Print("TitanTradersLiveSync: WebRequest failed. Error code: ", err);
   }
   return status;
}

bool IsHttpOk(int status) { return status == 200 || status == 201 || status == 204; }

//+------------------------------------------------------------------+
void ComputePipsForPosition(int idx, string &slPipsOut, string &tpPipsOut)
{
   slPipsOut = "";
   tpPipsOut = "";
   double ps = PipSizeOf(g_symbol[idx]);
   if(ps <= 0) return;
   if(g_sl[idx] > 0) slPipsOut = DoubleToString(MathAbs(g_openPrice[idx] - g_sl[idx]) / ps, 1);
   if(g_tp[idx] > 0) tpPipsOut = DoubleToString(MathAbs(g_tp[idx] - g_openPrice[idx]) / ps, 1);
}

//+------------------------------------------------------------------+
void UpsertLivePosition(int idx, double unrealized)
{
   string direction = (g_posType[idx] == POSITION_TYPE_BUY) ? "buy" : "sell";
   double realized = ComputeRealizedForPosition((ulong)g_tickets[idx]);
   double curVolume = PositionGetDouble(POSITION_VOLUME);
   double curPrice  = PositionGetDouble(POSITION_PRICE_CURRENT);
   string slPips, tpPips;
   ComputePipsForPosition(idx, slPips, tpPips);
   string slPipsJson = (slPips == "") ? "null" : ("\"" + slPips + "\"");
   string tpPipsJson = (tpPips == "") ? "null" : ("\"" + tpPips + "\"");

   string json = StringFormat(
      "[{\"ticket\":%I64d,\"user_id\":\"%s\",\"symbol\":\"%s\",\"direction\":\"%s\","
      "\"volume\":%s,\"original_volume\":%s,\"open_price\":%s,\"current_price\":%s,"
      "\"sl\":%s,\"tp\":%s,\"sl_pips\":%s,\"tp_pips\":%s,\"open_time\":\"%s\",\"unrealized_pnl\":%s,"
      "\"realized_pnl\":%s,\"status\":\"open\",\"updated_at\":\"%s\"}]",
      g_tickets[idx], InpUserId, g_symbol[idx], direction,
      DoubleToString(curVolume, 2), DoubleToString(g_origVolume[idx], 2),
      DoubleToString(g_openPrice[idx], 5), DoubleToString(curPrice, 5),
      DoubleToString(g_sl[idx], 5), DoubleToString(g_tp[idx], 5), slPipsJson, tpPipsJson,
      ToIso8601(g_openTime[idx]), DoubleToString(unrealized, 2),
      DoubleToString(realized, 2), ToIso8601(TimeCurrent())
   );

   string resp;
   int status = DoWebRequest("POST", g_restUrl + "/live_positions?on_conflict=ticket", json,
                "Prefer: resolution=merge-duplicates\r\n", resp);
   if(!IsHttpOk(status))
      Print("TitanTradersLiveSync: FAILED to upsert live position #", g_tickets[idx],
            ". HTTP status: ", status, ". Response: ", resp);
}

//+------------------------------------------------------------------+
void DeleteLivePosition(long ticket)
{
   string resp;
   int status = DoWebRequest("DELETE", g_restUrl + "/live_positions?ticket=eq." + IntegerToString(ticket), "", "", resp);
   if(!IsHttpOk(status))
      Print("TitanTradersLiveSync: FAILED to delete live position #", ticket,
            ". HTTP status: ", status, ". Response: ", resp);
}

//+------------------------------------------------------------------+
void InsertOpenTrade(int idx)
{
   // Creates the journal row the moment a position opens, so it shows up in
   // Trades right away instead of only appearing once it closes.
   string slPips, tpPips;
   ComputePipsForPosition(idx, slPips, tpPips);
   string direction = (g_posType[idx] == POSITION_TYPE_BUY) ? "buy" : "sell";

   string json = StringFormat(
      "[{\"user_id\":\"%s\",\"trade_date\":\"%s\",\"lot_size\":\"%s\","
      "\"sl_pips\":\"%s\",\"tp_pips\":\"%s\",\"is_draft\":true,"
      "\"mt5_ticket\":%I64d,\"mt5_symbol\":\"%s\",\"mt5_open_price\":%s,"
      "\"mt5_direction\":\"%s\"}]",
      InpUserId, ToDateOnly(g_openTime[idx]), DoubleToString(g_origVolume[idx], 2),
      slPips, tpPips, g_tickets[idx], g_symbol[idx],
      DoubleToString(g_openPrice[idx], 5), direction
   );

   string resp;
   int status = DoWebRequest("POST", g_restUrl + "/trades?on_conflict=mt5_ticket", json,
                "Prefer: return=minimal,resolution=ignore-duplicates\r\n", resp);

   if(IsHttpOk(status))
      Print("TitanTradersLiveSync: opened draft trade for position #", g_tickets[idx], " (", g_symbol[idx], ")");
   else
      Print("TitanTradersLiveSync: FAILED to create open draft trade for position #", g_tickets[idx],
            ". HTTP status: ", status, ". Response: ", resp);
}

//+------------------------------------------------------------------+
void CloseDraftTrade(int idx, double realized)
{
   string slPips, tpPips;
   ComputePipsForPosition(idx, slPips, tpPips);
   double avgClose = ComputeAvgClosePriceForPosition((ulong)g_tickets[idx]);

   // Auto-fill the real outcome so it counts toward Net P&L immediately;
   // is_draft stays true so you can still fill in zone/instrument details.
   string result = "be";
   if(realized > 0.00001) result = "win";
   else if(realized < -0.00001) result = "loss";

   string netProfitJson = (result == "win") ? DoubleToString(realized, 2) : "null";
   string netRiskJson   = (result == "loss") ? DoubleToString(MathAbs(realized), 2) : "null";
   string avgCloseJson  = (avgClose > 0) ? DoubleToString(avgClose, 5) : "null";

   // Update the SAME row that was created when the position opened, filling
   // in the final result and the volume-weighted average close price --
   // never a second row for the same ticket.
   string patchJson = StringFormat(
      "{\"sl_pips\":\"%s\",\"tp_pips\":\"%s\",\"result\":\"%s\","
      "\"net_profit\":%s,\"net_risk\":%s,\"mt5_close_price\":%s}",
      slPips, tpPips, result, netProfitJson, netRiskJson, avgCloseJson
   );

   string resp;
   int status = DoWebRequest("PATCH", g_restUrl + "/trades?mt5_ticket=eq." + IntegerToString(g_tickets[idx]),
                patchJson, "Prefer: return=representation\r\n", resp);

   if(IsHttpOk(status) && StringFind(resp, "\"id\"") >= 0)
   {
      Print("TitanTradersLiveSync: closed draft trade for position #", g_tickets[idx],
            " (", g_symbol[idx], "), realized P/L ", DoubleToString(realized, 2));
      return;
   }

   // Fallback: no existing row matched this ticket (e.g. the open-trade
   // insert never made it through) -- insert a complete row now so the
   // closed trade is never silently lost.
   string insertJson = StringFormat(
      "[{\"user_id\":\"%s\",\"trade_date\":\"%s\",\"lot_size\":\"%s\","
      "\"sl_pips\":\"%s\",\"tp_pips\":\"%s\",\"result\":\"%s\","
      "\"net_profit\":%s,\"net_risk\":%s,\"is_draft\":true,"
      "\"mt5_ticket\":%I64d,\"mt5_symbol\":\"%s\",\"mt5_close_price\":%s}]",
      InpUserId, ToDateOnly(TimeCurrent()), DoubleToString(g_origVolume[idx], 2),
      slPips, tpPips, result, netProfitJson, netRiskJson, g_tickets[idx], g_symbol[idx], avgCloseJson
   );
   int insStatus = DoWebRequest("POST", g_restUrl + "/trades?on_conflict=mt5_ticket", insertJson,
                "Prefer: return=minimal,resolution=ignore-duplicates\r\n", resp);

   if(IsHttpOk(insStatus))
      Print("TitanTradersLiveSync: draft trade created (fallback) for closed position #", g_tickets[idx],
            " (", g_symbol[idx], "), realized P/L ", DoubleToString(realized, 2));
   else
      Print("TitanTradersLiveSync: FAILED to record closed position #", g_tickets[idx],
            ". HTTP status: ", insStatus, ". Response: ", resp);
}

//+------------------------------------------------------------------+
void PollOnce()
{
   int total = PositionsTotal();
   long openTickets[];
   ArrayResize(openTickets, total);

   for(int i = 0; i < total; i++)
   {
      ulong ticket = PositionGetTicket(i);
      if(!PositionSelectByTicket(ticket)) continue;
      openTickets[i] = (long)ticket;

      int idx = FindTracked((long)ticket);
      if(idx < 0)
      {
         AddTrackedFromCurrent(ticket);
         idx = FindTracked((long)ticket);
         InsertOpenTrade(idx);
      }
      else
      {
         // keep SL/TP fresh in case you moved them (e.g. trailing stop, breakeven)
         g_sl[idx] = PositionGetDouble(POSITION_SL);
         g_tp[idx] = PositionGetDouble(POSITION_TP);
      }

      double unrealized = PositionGetDouble(POSITION_PROFIT);
      UpsertLivePosition(idx, unrealized);
   }

   // anything tracked that's no longer open has fully closed
   for(int i = ArraySize(g_tickets) - 1; i >= 0; i--)
   {
      long ticket = g_tickets[i];
      bool stillOpen = false;
      for(int j = 0; j < ArraySize(openTickets); j++)
         if(openTickets[j] == ticket) { stillOpen = true; break; }

      if(!stillOpen)
      {
         double realized = ComputeRealizedForPosition((ulong)ticket);
         CloseDraftTrade(i, realized);
         DeleteLivePosition(ticket);

         ArrayRemove(g_tickets, i, 1);
         ArrayRemove(g_origVolume, i, 1);
         ArrayRemove(g_openPrice, i, 1);
         ArrayRemove(g_sl, i, 1);
         ArrayRemove(g_tp, i, 1);
         ArrayRemove(g_openTime, i, 1);
         ArrayRemove(g_symbol, i, 1);
         ArrayRemove(g_posType, i, 1);
      }
   }
}
//+------------------------------------------------------------------+
supabase_migration_mt5.sql
Run this once in the Supabase SQL Editor before using either option above — it adds the live_positions table and draft columns.
-- ============================================================
-- TitanTraders: MT5 live sync migration
-- Run this once in Supabase Dashboard -> SQL Editor.
-- ============================================================

-- 1) Let "trades" hold partial draft rows created by the MT5 bridge.
alter table trades add column if not exists is_draft boolean not null default false;
alter table trades add column if not exists mt5_ticket bigint;
alter table trades add column if not exists mt5_symbol text;

-- Filled in when the position opens / fully closes (see
-- TitanTradersLiveSync.mq5's InsertOpenTrade / CloseDraftTrade).
alter table trades add column if not exists mt5_open_price numeric;
alter table trades add column if not exists mt5_close_price numeric;
alter table trades add column if not exists mt5_direction text;

-- Loosen NOT NULL constraints so a draft can be saved with only
-- lot_size / sl_pips / tp_pips filled in (no-op if already nullable).
alter table trades alter column result drop not null;
alter table trades alter column instrument drop not null;

-- Prevent the bridge script from creating duplicate drafts for the same
-- MT5 position if it restarts.
--
-- IMPORTANT: this must be a PLAIN unique index, not a partial one
-- ("where mt5_ticket is not null"). Postgres's ON CONFLICT inference
-- (which is what PostgREST's ?on_conflict=mt5_ticket generates) cannot
-- match a partial index unless the query repeats its exact WHERE clause,
-- which PostgREST never does. With a partial index here, every single
-- insert from the EA (open trade AND the closed-trade fallback insert)
-- was silently erroring with "no unique or exclusion constraint matching
-- the ON CONFLICT specification" -- which is why trades never appeared.
-- A plain unique index still allows unlimited manually-entered rows with
-- a NULL mt5_ticket (Postgres never treats NULLs as duplicates of each
-- other), so this is safe.
drop index if exists trades_mt5_ticket_uidx;
create unique index if not exists trades_mt5_ticket_uidx
  on trades (mt5_ticket);

-- 2) Table that mirrors your currently OPEN MT5 positions.
create table if not exists live_positions (
  id bigserial primary key,
  ticket bigint not null unique,
  user_id uuid not null references auth.users(id) on delete cascade,
  symbol text not null,
  direction text not null,
  volume numeric not null,
  original_volume numeric not null,
  open_price numeric not null,
  current_price numeric,
  sl numeric,
  tp numeric,
  open_time timestamptz not null,
  realized_pnl numeric not null default 0,
  unrealized_pnl numeric not null default 0,
  status text not null default 'open',
  closed_at timestamptz,
  updated_at timestamptz not null default now()
);

-- Pip-based risk/profit shown on the live position card (same units as the
-- trades table's sl_pips / tp_pips columns).
alter table live_positions add column if not exists sl_pips text;
alter table live_positions add column if not exists tp_pips text;

alter table live_positions enable row level security;

drop policy if exists "Users can view own live positions" on live_positions;
create policy "Users can view own live positions" on live_positions
  for select using (auth.uid() = user_id);

-- Intentionally no insert/update/delete policy for anon/authenticated roles.
-- Only the mt5_bridge.py script (using your Supabase service_role key) can
-- write to this table; the service role bypasses Row Level Security.