Files
gizerbridge/trade_parser.py
T

457 lines
16 KiB
Python

"""Sierra Chart TradeActivityLog parser.
Binary .data files are decoded field-by-field (TLV format):
[uint32 LE: field_id] [uint32 LE: length] [bytes * length]
Field 0x66 → SCDateTime (int64 LE, microseconds since 1899-12-30) ← timestamp
Field 0x67 → symbol string
Field 0x68 → log message text
parse_binary_log() returns [(datetime_or_None, str)] for each log entry.
All internal parsers work on this list; timestamps propagate to _make_trade.
Sim accounts (contain 'sim'/'simulated' in account name):
- Entry: User order entry ... AOE=true (not a Flatten)
- Entry price: first 'Trade simulation fill ... Last: X' after signal
- Direction: 'Modifying Attached Order from parent fill. Parent base price: B.
New price: P' → Long if P > B
- Exit type 1 (TP/SL): next simulation fill at price ≠ entry price
- Exit type 2 (Flatten): 'Flatten&CancelAllOrders | Last: X. Current Position
quantity: N' → exit = X, lots = |N|
Live accounts:
- Entry: Updated Internal Position Quantity to N. Previous: 0
- Entry price: first 'Modifying Attached Order from parent fill.
Parent base price: X' after entry update
- Exit: Updated Internal Position Quantity to 0. Previous: N
- Exit price (priority order):
1. 'Flatten&CancelAllOrders | Last: X'
2. 'Rithmic Direct - DTC (Filled)' + 'Limit' nearby → tp_price
3. 'Rithmic Direct - DTC (Filled)' + 'Stop' nearby → sl_price
4. 'User order entry | Last: X | AOE=true' (Reverse entry)
"""
import glob
import os
import re
import struct
from datetime import datetime, timedelta
_LOG_GLOB = "TradeActivityLog_*_UTC.{account}.data"
# SCDateTime: int64 microseconds since 1899-12-30
_SC_EPOCH = datetime(1899, 12, 30)
_SC_TS_MIN = int((datetime(2000, 1, 1) - _SC_EPOCH).total_seconds() * 1_000_000)
_SC_TS_MAX = int((datetime(2060, 1, 1) - _SC_EPOCH).total_seconds() * 1_000_000)
_FIELD_DATETIME = 0x66 # SCDateTime
_FIELD_MESSAGE = 0x68 # log message text
# ── File discovery ─────────────────────────────────────────────────────────────
def find_sc_log_dir():
"""Return first detected SC TradeActivityLogs directory, or None."""
patterns = [
"~/.local/share/bottles/bottles/*/drive_c/SierraChart*/TradeActivityLogs/",
"~/Games/SierraChart*/TradeActivityLogs/",
"~/Games/sierra-chart*/TradeActivityLogs/",
"~/.wine/drive_c/SierraChart*/TradeActivityLogs/",
"~/.PlayOnLinux/wineprefix/*/drive_c/SierraChart*/TradeActivityLogs/",
]
for pattern in patterns:
matches = glob.glob(os.path.expanduser(pattern))
if matches:
return matches[0]
return None
def find_log_files(log_dir, accounts):
"""Return [(filepath, account_name)] for all matching log files."""
if not log_dir or not os.path.isdir(log_dir):
return []
result = []
for account in accounts:
pattern = os.path.join(log_dir, _LOG_GLOB.format(account=account))
for path in glob.glob(pattern):
result.append((path, account))
return result
# ── Binary log parser ──────────────────────────────────────────────────────────
def parse_binary_log(filepath):
"""Read binary SC .data file.
Returns [(datetime_or_None, message_str)] — one entry per log line,
timestamped with the SCDateTime field that preceded it in the file.
"""
try:
with open(filepath, "rb") as f:
data = f.read()
except OSError:
return []
entries = []
i = 0
n = len(data)
cur_ts = None
while i + 8 <= n:
try:
field_id, field_len = struct.unpack_from("<II", data, i)
except struct.error:
break
i += 8
if i + field_len > n:
break
value = data[i : i + field_len]
i += field_len
if field_id == _FIELD_DATETIME and field_len == 8:
raw, = struct.unpack("<q", value)
cur_ts = (_SC_EPOCH + timedelta(microseconds=raw)
if _SC_TS_MIN < raw < _SC_TS_MAX else None)
elif field_id == _FIELD_MESSAGE and field_len > 0:
text = value.rstrip(b"\x00").decode("ascii", errors="replace")
if text.strip():
entries.append((cur_ts, text))
return entries
extract_strings = parse_binary_log # alias used by tray_app
# ── Public entry point ─────────────────────────────────────────────────────────
def parse_trades(entries, account, instruments, trade_date=None):
"""Parse all complete trades from [(datetime_or_None, str)] entry list."""
if _is_sim(account):
return _parse_sim(entries, account, instruments, trade_date)
return _parse_live(entries, account, instruments, trade_date)
# ── Shared helpers ─────────────────────────────────────────────────────────────
def _is_sim(account):
low = account.lower()
return "sim" in low or "simulated" in low
def _text(entry):
"""Extract the string from a (ts, str) entry."""
return entry[1]
def _ts(entry):
"""Extract the datetime (or None) from a (ts, str) entry."""
return entry[0]
def _extract_instrument(s, known):
m = re.search(r'\b([A-Z]{2,4})[A-Z]\d', s)
if m and m.group(1) in known:
return m.group(1)
return None
_PRICE = r"(\d+(?:\.\d+)?)" # matches 29744.25 but not a trailing bare period
def _ts_str(ts, trade_date=None):
"""Format a datetime for the API: actual time → HH:MM:SS; fallback → 00:00:00."""
if ts:
return ts.strftime("%Y-%m-%d %H:%M:%S")
if trade_date:
return f"{trade_date} 00:00:00"
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _find_direction(entries, start, n, window=40):
"""Long if first Modifying new_price > base, Short otherwise."""
for j in range(start, min(start + window, n)):
s = _text(entries[j])
if "Order modified internally" in s:
continue
m = re.search(
r"Modifying Attached Order from parent fill\."
r" Parent base price: " + _PRICE + r"\. New price: " + _PRICE, s)
if m:
return "Long" if float(m.group(2)) > float(m.group(1)) else "Short"
return None
def _nearby_order_type(entries, dtc_idx, n, window=4):
"""After DTC (Filled) at dtc_idx, look at nearby strings for order type."""
for k in range(dtc_idx + 1, min(dtc_idx + window, n)):
s = _text(entries[k])
if "Market" in s:
return "Market"
if "Limit" in s:
return "Limit"
if "Stop" in s:
return "Stop"
return None
def _make_trade(instrument, direction, lots, entry_price, exit_price,
account, instruments, entry_ts=None, exit_ts=None, trade_date=None):
cfg = instruments[instrument]
tick_size = cfg["tick_size"]
tick_value = cfg["tick_value"]
if direction == "Long":
pnl_ticks = round((exit_price - entry_price) / tick_size) * lots
else:
pnl_ticks = round((entry_price - exit_price) / tick_size) * lots
pnl = round(pnl_ticks * tick_value, 2)
entry_str = _ts_str(entry_ts, trade_date)
exit_str = _ts_str(exit_ts, trade_date)
if direction == "Long":
exec_str = (f"Buy {lots} @ {entry_price} / {entry_str},"
f"Sell {lots} @ {exit_price} / {exit_str}")
else:
exec_str = (f"Sell {lots} @ {entry_price} / {entry_str},"
f"Buy {lots} @ {exit_price} / {exit_str}")
return {
"trade_id": f"{account}_{instrument}_{entry_price}_{exit_price}",
"instrument": instrument,
"direction": direction,
"lots": lots,
"entry_price": entry_price,
"exit_price": exit_price,
"entry_datetime": entry_str,
"exit_datetime": exit_str,
"pnl": pnl,
"pnl_ticks": pnl_ticks,
"executions": exec_str,
"account": account,
}
# ── Sim parser ─────────────────────────────────────────────────────────────────
def _parse_sim(entries, account, instruments, trade_date=None):
trades = []
current_inst = None
i = 0
n = len(entries)
while i < n:
s = _text(entries[i])
inst = _extract_instrument(s, instruments)
if inst:
current_inst = inst
if (re.search(r"User order entry.+AOE=true", s)
and "Flatten" not in s
and current_inst):
entry_idx, entry_price = _sim_next_fill(entries, i + 1, n)
if entry_idx is None:
i += 1
continue
direction = _find_direction(entries, i + 1, n)
exit_idx, exit_price, lots, exit_direction = _sim_find_exit(
entries, entry_idx + 1, n, entry_price)
if exit_idx is None:
i += 1
continue
if direction is None:
direction = exit_direction
if direction is None:
i += 1
continue
if lots is None:
lots = 1
entry_ts = _ts(entries[entry_idx])
exit_ts = _ts(entries[exit_idx])
trades.append(_make_trade(
current_inst, direction, lots,
entry_price, exit_price, account, instruments,
entry_ts=entry_ts, exit_ts=exit_ts, trade_date=trade_date))
i = exit_idx + 1
continue
i += 1
return trades
def _sim_next_fill(entries, start, n, window=60):
"""Return (idx, last_price) of next simulation fill."""
for j in range(start, min(start + window, n)):
m = re.search(
r"simulation fill\. Bid: \S+ Ask: \S+ Last: " + _PRICE,
_text(entries[j]))
if m:
return j, float(m.group(1))
return None, None
def _sim_find_exit(entries, start, n, entry_price, window=400):
"""Find Sim trade exit. Returns (exit_idx, exit_price, lots, direction_or_None)."""
for j in range(start, min(start + window, n)):
s = _text(entries[j])
m_flat = re.search(
r"Flatten&CancelAllOrders \| Last: " + _PRICE
+ r".*?Current Position quantity: (-?\d+)", s)
if m_flat:
qty = int(m_flat.group(2))
direction = "Long" if qty > 0 else "Short"
return j, float(m_flat.group(1)), abs(qty), direction
m_fill = re.search(
r"simulation fill\. Bid: \S+ Ask: \S+ Last: " + _PRICE, s)
if m_fill:
price = float(m_fill.group(1))
if abs(price - entry_price) > 0.1:
fill_type = _nearby_order_type(entries, j, n)
if fill_type == "Limit":
direction = "Long" if price > entry_price else "Short"
elif fill_type == "Stop":
direction = "Long" if price < entry_price else "Short"
else:
direction = None
return j, price, None, direction
return None, None, None, None
# ── Live parser ────────────────────────────────────────────────────────────────
def _parse_live(entries, account, instruments, trade_date=None):
trades = []
current_inst = None
last_signal_price = None
i = 0
n = len(entries)
while i < n:
s = _text(entries[i])
inst = _extract_instrument(s, instruments)
if inst:
current_inst = inst
m_sig = re.search(
r"User order entry \| Last: " + _PRICE + r" \| AOE=true", s)
if m_sig and "Flatten" not in s:
last_signal_price = float(m_sig.group(1))
m_entry = re.search(
r"Updated Internal Position Quantity to (-?\d+)\. Previous: 0", s)
if m_entry and current_inst:
new_qty = int(m_entry.group(1))
if new_qty == 0:
i += 1
continue
direction = "Long" if new_qty > 0 else "Short"
lots = abs(new_qty)
entry_ts = _ts(entries[i])
entry_price, tp_price, sl_price = _live_bracket_prices(entries, i + 1, n)
if entry_price is None:
entry_price = last_signal_price
if entry_price is None:
i += 1
continue
exit_idx = _live_exit_idx(entries, i + 1, n)
if exit_idx is None:
i += 1
continue
exit_price = _live_exit_price(entries, i + 1, exit_idx, tp_price, sl_price, n)
if exit_price is None:
i += 1
continue
exit_ts = _ts(entries[exit_idx])
last_signal_price = None
trades.append(_make_trade(
current_inst, direction, lots,
entry_price, exit_price, account, instruments,
entry_ts=entry_ts, exit_ts=exit_ts, trade_date=trade_date))
i = exit_idx + 1
continue
i += 1
return trades
def _live_bracket_prices(entries, start, n, window=40):
"""Return (entry_price, tp_price, sl_price) from bracket modification strings."""
entry_price = None
bracket_new_prices = []
for j in range(start, min(start + window, n)):
s = _text(entries[j])
if "Order modified internally" in s:
continue
m = re.search(
r"Modifying Attached Order from parent fill\."
r" Parent base price: " + _PRICE + r"\. New price: " + _PRICE, s)
if m:
if entry_price is None:
entry_price = float(m.group(1))
bracket_new_prices.append(float(m.group(2)))
if len(bracket_new_prices) >= 2:
break
tp_price = bracket_new_prices[0] if len(bracket_new_prices) >= 1 else None
sl_price = bracket_new_prices[1] if len(bracket_new_prices) >= 2 else None
return entry_price, tp_price, sl_price
def _live_exit_idx(entries, start, n):
"""Find index of position returning to 0."""
for j in range(start, n):
if re.search(
r"Updated Internal Position Quantity to 0\. Previous: -?\d+",
_text(entries[j])):
return j
return None
def _live_exit_price(entries, start, exit_idx, tp_price, sl_price, n):
"""Determine exit price from events between entry and exit position updates."""
for j in range(start, exit_idx):
m = re.search(r"Flatten&CancelAllOrders \| Last: " + _PRICE,
_text(entries[j]))
if m:
return float(m.group(1))
for j in range(start, exit_idx):
if "Rithmic Direct - DTC (Filled)" in _text(entries[j]):
otype = _nearby_order_type(entries, j, exit_idx)
if otype == "Limit" and tp_price is not None:
return tp_price
if otype == "Stop" and sl_price is not None:
return sl_price
for j in range(start, exit_idx):
s = _text(entries[j])
m = re.search(r"User order entry \| Last: " + _PRICE + r" \| AOE=true", s)
if m and "Flatten" not in s:
return float(m.group(1))
return None