auth.users table (or use Supabase Authentication → Users → Invite user) so the teammate has their own login.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:
TitanTradersLiveSync.mq5 (see Step 3 below for the full code) into your MT5 MQL5/Experts folder (in MT5: File → Open Data Folder → MQL5 → Experts).https://sbnkfngrkblwsjboqdwx.supabase.co).InpServiceRoleKey (Supabase Dashboard → Project Settings → API → service_role secret key) and InpUserId (Supabase Dashboard → Authentication → Users → your account's UUID).pip install MetaTrader5 requests
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.python mt5_bridge.py
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.
mt5_bridge.py config, not in the website's public files.These are the exact files referenced above. Copy each one into place as described in Step 2.
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 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);
}
}
}
//+------------------------------------------------------------------+
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.