diff --git a/__pycache__/view_trade_analysis.cpython-314.pyc b/__pycache__/view_trade_analysis.cpython-314.pyc index 1bd2e85..bbb3962 100644 Binary files a/__pycache__/view_trade_analysis.cpython-314.pyc and b/__pycache__/view_trade_analysis.cpython-314.pyc differ diff --git a/icmarkets_parser.py b/icmarkets_parser.py new file mode 100644 index 0000000..af49cd2 --- /dev/null +++ b/icmarkets_parser.py @@ -0,0 +1,172 @@ +""" +icmarkets_parser.py +=================== +Parser for IC Markets MT5 Position History XLSX exports. + +The file contains one sheet ("MT5 Position history List") with: + - Row 0: "Report" + - Row 1: Name / Produced At metadata + - Row 2: Column headers + - Row 3+: Deal rows (one row per leg — In or Out) + +Each closed trade has two legs sharing the same Position ID: + - "Trade Buy In" / "Trade Sell In" → entry leg (Profit = 0) + - "Trade Buy Out" / "Trade Sell Out" → exit leg (Profit = actual P/L) + +Open positions have only an In leg (no Out yet). + +Usage +----- + from icmarkets_parser import parse_icmarkets_xlsx, get_icmarkets_accounts + + # Get list of accounts in the file + accounts = get_icmarkets_accounts(file_bytes) + # → ['11586098', '11586099'] + + # Parse all accounts (returns combined DataFrame) + df = parse_icmarkets_xlsx(file_bytes) + + # Parse a specific account + df = parse_icmarkets_xlsx(file_bytes, account="11586098") + +Output columns (normalised to match mt5_parser schema) +------------------------------------------------------- + symbol, type, open_time, close_time, open_price, close_price, + volume, net_profit, win, commission, swap, comment, + _account, _strategy, position_id, position_status, + open_date, close_date, day_of_week, hour, duration_min, + symbol_base +""" + +import io +import pandas as pd +import numpy as np + + +# ───────────────────────────────────────────────────────────────────────────── +# Public helpers +# ───────────────────────────────────────────────────────────────────────────── + +def get_icmarkets_accounts(file_bytes: bytes) -> list[str]: + """Return sorted list of account numbers found in the file.""" + raw = _read_raw(file_bytes) + if raw is None: + return [] + accounts = raw["Account Number"].dropna().unique().tolist() + return sorted([str(a) for a in accounts if str(a).strip()]) + + +def parse_icmarkets_xlsx(file_bytes: bytes, account: str = None) -> pd.DataFrame: + """ + Parse IC Markets position history XLSX into a normalised trade DataFrame. + + Parameters + ---------- + file_bytes : bytes + Raw bytes of the .xlsx file. + account : str, optional + If provided, only trades for this account number are returned. + If None, all accounts are combined. + + Returns + ------- + pd.DataFrame with normalised columns ready for use in mt5_parser-compatible + analysis pages. + """ + raw = _read_raw(file_bytes) + if raw is None or raw.empty: + return pd.DataFrame() + + # Filter by account if requested + if account: + raw = raw[raw["Account Number"].astype(str) == str(account)].copy() + if raw.empty: + return pd.DataFrame() + + # ── Separate In (entry) and Out (exit) legs ──────────────────────────── + ins = raw[raw["Transaction Type"].str.contains("In", na=False)].copy() + outs = raw[raw["Transaction Type"].str.contains("Out", na=False)].copy() + + for df in (ins, outs): + df["Position"] = pd.to_numeric(df["Position"], errors="coerce") + + # ── Pair by Position ID ──────────────────────────────────────────────── + paired = ins.merge( + outs[["Position", "Date Time", "Open Price", "Profit", + "Transaction Type"]].rename(columns={ + "Date Time": "Date Time_close", + "Open Price": "Close Price", + "Profit": "Profit_close", + "Transaction Type": "TT_close", + }), + on="Position", + how="left", # keep open positions too (no Out leg yet) + ) + + # ── Build normalised columns ─────────────────────────────────────────── + out = pd.DataFrame() + out["position_id"] = paired["Position"] + out["symbol"] = paired["Symbol"].astype(str) + out["symbol_base"] = out["symbol"].str.split(".").str[0] + out["_account"] = paired["Account Number"].astype(str) + out["position_status"] = paired["Position Status"].astype(str) + + # Direction from the In leg transaction type + out["type"] = paired["Transaction Type"].apply( + lambda x: "buy" if "Buy" in str(x) else "sell" + ) + + out["open_time"] = pd.to_datetime(paired["Date Time"], errors="coerce") + out["close_time"] = pd.to_datetime(paired["Date Time_close"], errors="coerce") + out["open_price"] = pd.to_numeric(paired["Open Price"], errors="coerce") + out["close_price"] = pd.to_numeric(paired["Close Price"], errors="coerce") + out["volume"] = pd.to_numeric(paired["Trade Volume Lots"],errors="coerce") + out["net_profit"] = pd.to_numeric(paired["Profit_close"], errors="coerce").fillna(0) + out["commission"] = 0.0 # IC Markets file doesn't separate commission + out["swap"] = 0.0 + out["comment"] = "" + out["_strategy"] = out["symbol_base"] # use symbol as strategy label + + # ── Derived columns ──────────────────────────────────────────────────── + out["win"] = out["net_profit"] > 0 + out["open_date"] = out["open_time"].dt.date + out["close_date"] = out["close_time"].dt.date + out["day_of_week"] = out["open_time"].dt.day_name() + out["hour"] = out["open_time"].dt.hour + out["duration_min"] = ((out["close_time"] - out["open_time"]) + .dt.total_seconds() / 60).round(1) + + return out.reset_index(drop=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# Internal helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _read_raw(file_bytes: bytes) -> pd.DataFrame | None: + """Read the raw sheet and return a DataFrame with named columns.""" + try: + buf = io.BytesIO(file_bytes) + df = pd.read_excel(buf, sheet_name=0, header=None, dtype=str) + except Exception: + return None + + # Find the header row — it contains "Symbol" in column 0 + header_row = None + for i, row in df.iterrows(): + if str(row.iloc[0]).strip() == "Symbol": + header_row = i + break + + if header_row is None: + return None + + df.columns = df.iloc[header_row].tolist() + df = df.iloc[header_row + 1:].copy() + df.columns.name = None + + # Keep only real data rows (Position Status = Open or Closed) + if "Position Status" in df.columns: + df = df[df["Position Status"].isin(["Open", "Closed"])].copy() + + return df.reset_index(drop=True) \ No newline at end of file diff --git a/view_trade_analysis.py b/view_trade_analysis.py index 19eb112..a9b2f7a 100644 --- a/view_trade_analysis.py +++ b/view_trade_analysis.py @@ -11,37 +11,132 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from mt5_parser import detect_and_parse, calc_stats + +def _normalise_ic(df): + """Map IC Markets DataFrame columns to the schema expected by calc_stats.""" + import pandas as pd + out = df.copy() + # calc_stats / render helpers need: open_time, close_time, symbol, type, + # strategy, net_profit, win, volume, open_price, close_price, + # commission, swap, profit, duration_min, day_of_week, hour + if "symbol_base" in out.columns and "strategy" not in out.columns: + out["strategy"] = out["symbol_base"] + if "net_profit" in out.columns and "profit" not in out.columns: + out["profit"] = out["net_profit"] + if "commission" not in out.columns: + out["commission"] = 0.0 + if "swap" not in out.columns: + out["swap"] = 0.0 + if "sl" not in out.columns: + out["sl"] = None + if "tp" not in out.columns: + out["tp"] = None + # Ensure win column + if "win" not in out.columns and "net_profit" in out.columns: + out["win"] = out["net_profit"] > 0 + # Ensure day_of_week and hour + if "open_time" in out.columns: + out["open_time"] = pd.to_datetime(out["open_time"], errors="coerce") + if "day_of_week" not in out.columns: + out["day_of_week"] = out["open_time"].dt.day_name() + if "hour" not in out.columns: + out["hour"] = out["open_time"].dt.hour + if "close_time" in out.columns: + out["close_time"] = pd.to_datetime(out["close_time"], errors="coerce") + if "duration_min" not in out.columns and "open_time" in out.columns and "close_time" in out.columns: + out["duration_min"] = ((out["close_time"] - out["open_time"]) + .dt.total_seconds() / 60).round(1) + return out + + def render(): st.title("📊 Trade Analysis") # ── Session state ───────────────────────────────────────────────────────── - if 'ta_df' not in st.session_state: - st.session_state['ta_df'] = None - st.session_state['ta_format'] = None + for _k, _v in { + 'ta_df': None, 'ta_format': None, + 'ta_accounts': [], 'ta_ic_bytes': None, + }.items(): + if _k not in st.session_state: + st.session_state[_k] = _v - # ── File upload ─────────────────────────────────────────────────────────── - col1, col2 = st.columns([4, 1]) - with col1: - uploaded = st.file_uploader( - "Upload MT5 Report (HTM/HTML) or Quant Analyzer CSV", - type=['html', 'htm', 'csv'], - key='ta_upload' + # ── Source selector ────────────────────────────────────────────────────── + src_col1, src_col2 = st.columns([4, 1]) + with src_col1: + source = st.radio( + "File source", + ["MT5 / Quant Analyzer", "IC Markets XLSX"], + horizontal=True, key='ta_source', ) - with col2: + with src_col2: st.markdown("
", unsafe_allow_html=True) if st.button("🗑 Clear", key='ta_clear'): - st.session_state['ta_df'] = None - st.session_state['ta_format'] = None + st.session_state['ta_df'] = None + st.session_state['ta_format'] = None + st.session_state['ta_accounts'] = [] st.rerun() - if uploaded: - df, fmt = detect_and_parse(uploaded.read(), uploaded.name) - if df is not None: - st.session_state['ta_df'] = df - st.session_state['ta_format'] = fmt - st.success(f"✓ Loaded {len(df)} trades — {fmt}") - else: - st.error("Could not parse report — check file format") + # ── File upload ─────────────────────────────────────────────────────────── + if source == "MT5 / Quant Analyzer": + uploaded = st.file_uploader( + "Upload MT5 Report (HTM/HTML) or Quant Analyzer CSV", + type=None, key='ta_upload', + ) + if uploaded and uploaded.name.lower().endswith(('.htm','.html','.csv')): + df, fmt = detect_and_parse(uploaded.read(), uploaded.name) + if df is not None: + st.session_state['ta_df'] = df + st.session_state['ta_format'] = fmt + st.session_state['ta_accounts'] = [] + st.success(f"✓ Loaded {len(df)} trades — {fmt}") + else: + st.error("Could not parse report — check file format") + elif uploaded: + st.warning("Please upload a .htm, .html, or .csv file.") + + else: # IC Markets XLSX + uploaded = st.file_uploader( + "Upload IC Markets Position History (.xlsx)", + type=None, key='ta_upload', + ) + if uploaded and uploaded.name.lower().endswith(('.xlsx','.xls')): + try: + from icmarkets_parser import get_icmarkets_accounts, parse_icmarkets_xlsx + except ImportError as e: + st.error(f"icmarkets_parser.py not found — ensure it is in the MT5Tools folder. ({e})") + uploaded = None + if uploaded: + try: + file_bytes = uploaded.read() + accounts = get_icmarkets_accounts(file_bytes) + if not accounts: + st.error("No accounts found — check this is an IC Markets Position History export.") + else: + st.session_state['ta_ic_bytes'] = file_bytes + st.session_state['ta_accounts'] = accounts + st.session_state['ta_format'] = "IC Markets XLSX" + df_ic = parse_icmarkets_xlsx(file_bytes, account=accounts[0]) + df_ic = _normalise_ic(df_ic) + st.session_state['ta_df'] = df_ic + st.success(f"✓ Loaded {len(df_ic)} trades — {len(accounts)} account(s) found") + except Exception as e: + st.error(f"Error parsing file: {e}") + import traceback; st.code(traceback.format_exc()) + elif uploaded: + st.warning("Please upload an .xlsx file.") + + # IC Markets account selector (shown after upload) + if (st.session_state.get('ta_accounts') and + st.session_state.get('ta_source', source) == "IC Markets XLSX"): + accounts = st.session_state['ta_accounts'] + ac_opts = ["All accounts"] + accounts + sel_ac = st.selectbox("Account", ac_opts, key='ta_ic_account') + acct = None if sel_ac == "All accounts" else sel_ac + if st.session_state.get('ta_ic_bytes'): + from icmarkets_parser import parse_icmarkets_xlsx + df_ic = parse_icmarkets_xlsx(st.session_state['ta_ic_bytes'], account=acct) + df_ic = _normalise_ic(df_ic) + st.session_state['ta_df'] = df_ic df_all = st.session_state['ta_df'] fmt = st.session_state['ta_format'] @@ -330,4 +425,4 @@ def render(): data = df[show_cols].to_csv(index=False), file_name = f"mt5_trades_{date_from}_{date_to}.csv", mime = 'text/csv' - ) + ) \ No newline at end of file