← Back
β˜†
"""
TraderMakeMoney (TMM) Journal Integration.

Auto-tags trades by strategy (WT/Scalp/Gerchik),
posts daily summaries, and syncs trade descriptions.

API docs: https://tradermake.money/api/v2/docs/v2
"""

import logging
import os
import time
from datetime import datetime, timezone, timedelta
from typing import Optional

import requests

logger = logging.getLogger(__name__)

VANCOUVER_TZ = timezone(timedelta(hours=-7))

TMM_API_KEY = os.environ.get("TMM_API_KEY", "")
TMM_BASE_URL = "https://tradermake.money/api/v2"
TMM_API_KEY_ID = int(os.environ.get("TMM_API_KEY_ID", "276317"))  # Binance key ID in TMM

# Tag column IDs (created in TMM)
TAG_COL_STRATEGY = int(os.environ.get("TMM_TAG_COL_STRATEGY", "10"))  # "БтратСгия"
TAG_COL_ENTRY = 1    # "ΠŸΡ€ΠΈΡ‡ΠΈΠ½Ρ‹ Π²Ρ…ΠΎΠ΄Π°" (pre-existing)
TAG_COL_EXIT = 2     # "ΠŸΡ€ΠΈΡ‡ΠΈΠ½Ρ‹ Π²Ρ‹Ρ…ΠΎΠ΄Π°" (pre-existing)

# Strategy tag names
STRATEGY_TAGS = {
    "WT": "WT",
    "SCALP": "Scalp",
    "GERCHIK": "Gerchik",
}

# Gerchik model tags
MODEL_TAGS = {
    "A": "ΠžΡ‚Π±ΠΎΠΉ ΠΎΡ‚ уровня",
    "B": "Π›ΠΎΠΆΠ½Ρ‹ΠΉ ΠΏΡ€ΠΎΠ±ΠΎΠΉ",
    "C": "Π‘Π»ΠΎΠΆΠ½Ρ‹ΠΉ Π»ΠΎΠΆΠ½Ρ‹ΠΉ ΠΏΡ€ΠΎΠ±ΠΎΠΉ",
    "D": "ΠŸΡ€ΠΎΠ±ΠΎΠΉ уровня",
}


class TMMClient:
    """TraderMakeMoney API client for journal integration."""

    def __init__(self):
        self.api_key = TMM_API_KEY
        self.base_url = TMM_BASE_URL
        self.session = requests.Session()
        self.session.headers.update({
            "API-KEY": self.api_key,
            "Content-Type": "application/json",
        })
        self.enabled = bool(self.api_key)
        if self.enabled:
            logger.info("TMM journal integration enabled")
        else:
            logger.info("TMM journal integration disabled (no API key)")

        # Cache: symbol+open_time -> tmm_trade_id (avoid repeated lookups)
        self._trade_cache: dict[str, int] = {}
        self._pending_tags: list = []

    def _get(self, path: str, params: dict = None) -> Optional[dict]:
        """GET request to TMM API."""
        if not self.enabled:
            return None
        try:
            resp = self.session.get(f"{self.base_url}{path}", params=params, timeout=10)
            resp.raise_for_status()
            return resp.json()
        except Exception as e:
            logger.error(f"TMM GET {path} failed: {e}")
            return None

    def _post(self, path: str, data: dict = None) -> Optional[dict]:
        """POST request to TMM API."""
        if not self.enabled:
            return None
        try:
            resp = self.session.post(f"{self.base_url}{path}", json=data, timeout=10)
            resp.raise_for_status()
            return resp.json()
        except Exception as e:
            logger.error(f"TMM POST {path} failed: {e}")
            return None

    # ── Trade lookup ──────────────────────────────────────────────

    def find_trade_by_symbol(self, symbol: str, side: str,
                              open_time_ms: int = 0,
                              search_window_ms: int = 60_000) -> Optional[int]:
        """
        Find a TMM trade ID by symbol and approximate open time.
        TMM auto-imports trades from Binance, so we search by symbol+time.

        Returns TMM trade ID or None.
        """
        cache_key = f"{symbol}_{side}_{open_time_ms}"
        if cache_key in self._trade_cache:
            return self._trade_cache[cache_key]

        # Get recent trades sorted by open_time desc
        result = self._get("/trades", params={
            "page": 1,
            "itemsPerPage": 20,
            "sortBy": "open_time",
            "sortDesc": "true",
        })
        if not result or "data" not in result:
            return None

        for trade in result["data"]:
            if trade["symbol"] != symbol:
                continue
            t_side = trade["side"].upper()
            if t_side == "LONG" and side != "BUY":
                continue
            if t_side == "SHORT" and side != "SELL":
                continue

            # If we have open_time, match within window
            if open_time_ms:
                t_open = trade["open_time"]
                if abs(t_open - open_time_ms) <= search_window_ms:
                    self._trade_cache[cache_key] = trade["id"]
                    return trade["id"]
            else:
                # No time filter β€” return first matching symbol+side
                self._trade_cache[cache_key] = trade["id"]
                return trade["id"]

        return None

    def find_recent_trade(self, symbol: str, side: str) -> Optional[int]:
        """Find the most recent TMM trade for symbol+side (within last 5 min)."""
        now_ms = int(time.time() * 1000)
        return self.find_trade_by_symbol(symbol, side, now_ms, search_window_ms=300_000)

    # ── Tag management ────────────────────────────────────────────

    def tag_trade(self, trade_id: int, tag_name: str, column: int = TAG_COL_STRATEGY) -> bool:
        """Add a tag to a trade. Creates tag if it doesn't exist."""
        result = self._post(f"/trades/{trade_id}/tags", {
            "tags": [{"name": tag_name, "column": column}],
        })
        if result and result.get("status") == "success":
            logger.info(f"TMM: tagged trade #{trade_id} with '{tag_name}'")
            return True
        return False

    def tag_strategy(self, trade_id: int, strategy: str, model: str = "") -> bool:
        """Tag a trade with strategy name and optional model."""
        tag_name = STRATEGY_TAGS.get(strategy, strategy)
        ok = self.tag_trade(trade_id, tag_name, TAG_COL_STRATEGY)

        # Add model tag for Gerchik
        if model and model in MODEL_TAGS:
            self.tag_trade(trade_id, MODEL_TAGS[model], TAG_COL_ENTRY)

        return ok

    # ── Trade description ─────────────────────────────────────────

    def update_description(self, trade_id: int, description: str) -> bool:
        """Update trade description/notes."""
        result = self._post(f"/trades/{trade_id}/update", {
            "description": description,
        })
        if result and result.get("status") == "success":
            logger.debug(f"TMM: updated description for trade #{trade_id}")
            return True
        return False

    # ── Daily notes ───────────────────────────────────────────────

    def post_daily_note(self, date_str: str, content: str) -> bool:
        """
        Create or update a daily journal note.
        date_str: "2026-03-29" format
        content: markdown text for the day
        """
        # Check if note already exists for this date
        existing = self._get("/analyzer/notes", params={"type": 2})
        note_id = None
        if existing and existing.get("status") == "success":
            notes = existing.get("data", {}).get("2", [])
            for n in notes:
                if n.get("note_at") == date_str:
                    note_id = n["id"]
                    break

        payload = {
            "type": 2,  # 2 = day note
            "note_at": date_str,
            "desc": content,
        }
        if note_id:
            payload["id"] = note_id

        result = self._post("/analyzer/notes", payload)
        if result and result.get("status") == "success":
            logger.info(f"TMM: daily note {'updated' if note_id else 'created'} for {date_str}")
            return True
        return False

    # ── High-level: auto-tag on trade events ──────────────────────

    def on_trade_opened(self, symbol: str, side: str, strategy: str,
                         model: str = "", signal_info: str = ""):
        """
        Called when bot opens a trade. Finds it in TMM and tags it.
        TMM may take a few seconds to import the trade from Binance.
        """
        if not self.enabled:
            return

        # Delay slightly to let TMM import the trade
        time.sleep(3)

        trade_id = self.find_recent_trade(symbol, side)
        if not trade_id:
            logger.warning(f"TMM: trade not found for {symbol} {side}, will retry later")
            # Store for retry
            self._pending_tags.append({
                "symbol": symbol, "side": side, "strategy": strategy,
                "model": model, "signal_info": signal_info,
                "attempts": 1, "next_retry": time.time() + 10,
            })
            return

        self._apply_tags(trade_id, strategy, model, signal_info)

    def _apply_tags(self, trade_id: int, strategy: str, model: str = "",
                     signal_info: str = ""):
        """Apply strategy tag + description to a trade."""
        self.tag_strategy(trade_id, strategy, model)

        if signal_info:
            self.update_description(trade_id, signal_info)

    def retry_pending_tags(self):
        """Retry tagging trades that weren't found immediately."""
        if not self.enabled or not hasattr(self, '_pending_tags'):
            return

        now = time.time()
        remaining = []
        for item in self._pending_tags:
            if now < item["next_retry"]:
                remaining.append(item)
                continue

            trade_id = self.find_recent_trade(item["symbol"], item["side"])
            if trade_id:
                self._apply_tags(trade_id, item["strategy"],
                                 item.get("model", ""), item.get("signal_info", ""))
            elif item["attempts"] < 5:
                item["attempts"] += 1
                item["next_retry"] = now + 15
                remaining.append(item)
            else:
                logger.warning(f"TMM: gave up tagging {item['symbol']} after 5 attempts")

        self._pending_tags = remaining

    # ── Summaries ─────────────────────────────────────────────────

    def _get_trades_for_period(self, date_from: str, date_to: str) -> list:
        """Fetch trades between two dates (YYYY-MM-DD)."""
        result = self._get("/trades", params={
            "api_key_id": TMM_API_KEY_ID,
            "date_from": date_from,
            "date_to": date_to,
            "page": 1,
            "itemsPerPage": 100,
            "sortBy": "open_time",
            "sortDesc": "true",
        })
        if result and "data" in result:
            return result["data"]
        return []

    def _format_trades_summary(self, trades: list, title: str) -> str:
        """Format a list of trades into a summary message."""
        if not trades:
            return f"{title}\n━━━━━━━━━━━━━━━━━━━━\nπŸ“­ НСт сдСлок Π·Π° ΠΏΠ΅Ρ€ΠΈΠΎΠ΄"

        # Filter only closed trades (process=0 or close_time > 0)
        closed = [t for t in trades if int(t.get("close_time", 0)) > 0]
        open_trades = [t for t in trades if int(t.get("close_time", 0)) == 0]

        if not closed and not open_trades:
            return f"{title}\n━━━━━━━━━━━━━━━━━━━━\nπŸ“­ НСт сдСлок Π·Π° ΠΏΠ΅Ρ€ΠΈΠΎΠ΄"

        count = len(closed)
        wins = sum(1 for t in closed if float(t.get("net_profit", 0)) > 0)
        losses = count - wins
        total_pnl = sum(float(t.get("net_profit", 0)) for t in closed)
        wr = (wins / count * 100) if count > 0 else 0

        win_pnls = [float(t["net_profit"]) for t in closed if float(t.get("net_profit", 0)) > 0]
        loss_pnls = [float(t["net_profit"]) for t in closed if float(t.get("net_profit", 0)) <= 0]
        avg_win = sum(win_pnls) / len(win_pnls) if win_pnls else 0
        avg_loss = sum(loss_pnls) / len(loss_pnls) if loss_pnls else 0

        # Best and worst trade
        best = max(closed, key=lambda t: float(t.get("net_profit", 0))) if closed else None
        worst = min(closed, key=lambda t: float(t.get("net_profit", 0))) if closed else None

        emoji = "🟒" if total_pnl >= 0 else "πŸ”΄"

        lines = [
            f"{title}",
            f"━━━━━━━━━━━━━━━━━━━━",
            f"{emoji} PnL: ${total_pnl:+.2f}",
            f"πŸ“ˆ Π—Π°ΠΊΡ€Ρ‹Ρ‚ΠΎ: {count} ({wins}W / {losses}L)",
            f"🎯 Win Rate: {wr:.1f}%",
        ]
        if win_pnls:
            lines.append(f"πŸ’š Avg Win: ${avg_win:.2f}")
        if loss_pnls:
            lines.append(f"πŸ’” Avg Loss: ${avg_loss:.2f}")
        if best:
            lines.append(f"πŸ† Best: {best['symbol']} ${float(best['net_profit']):+.2f}")
        if worst and count > 1:
            lines.append(f"πŸ’© Worst: {worst['symbol']} ${float(worst['net_profit']):+.2f}")
        if open_trades:
            lines.append(f"⏳ ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎ: {len(open_trades)} сдСлок")
        lines.append("━━━━━━━━━━━━━━━━━━━━")

        return "\n".join(lines)

    def generate_today_summary(self) -> str:
        """Today's trading summary."""
        today = datetime.now(VANCOUVER_TZ).strftime("%Y-%m-%d")
        trades = self._get_trades_for_period(today, today)
        return self._format_trades_summary(trades, f"πŸ“Š Π‘Π²ΠΎΠ΄ΠΊΠ° Π·Π° сСгодня ({today})")

    def generate_weekly_summary(self) -> str:
        """Current week summary (Mon-Sun)."""
        now = datetime.now(VANCOUVER_TZ)
        monday = now - timedelta(days=now.weekday())
        sunday = monday + timedelta(days=6)
        trades = self._get_trades_for_period(
            monday.strftime("%Y-%m-%d"), sunday.strftime("%Y-%m-%d")
        )
        return self._format_trades_summary(
            trades,
            f"πŸ“Š НСдСля ({monday.strftime('%d.%m')} β†’ {sunday.strftime('%d.%m')})"
        )

    def generate_monthly_summary(self) -> str:
        """Current month summary."""
        now = datetime.now(VANCOUVER_TZ)
        first_day = now.replace(day=1).strftime("%Y-%m-%d")
        # Last day of month
        if now.month == 12:
            last_day = now.replace(year=now.year + 1, month=1, day=1) - timedelta(days=1)
        else:
            last_day = now.replace(month=now.month + 1, day=1) - timedelta(days=1)
        trades = self._get_trades_for_period(first_day, last_day.strftime("%Y-%m-%d"))
        month_name = now.strftime("%B %Y")
        return self._format_trades_summary(trades, f"πŸ“Š ΠœΠ΅ΡΡΡ†: {month_name}")

    def generate_pnl_by_strategy(self) -> str:
        """PnL breakdown by strategy tag β€” source of truth from Binance via TMM."""
        # Get ALL trades (no date filter β€” full history)
        result = self._get("/trades", params={
            "api_key_id": TMM_API_KEY_ID,
            "page": 1,
            "itemsPerPage": 200,
            "sortBy": "open_time",
            "sortDesc": "true",
        })
        if not result or "data" not in result:
            return "πŸ“Š TMM: Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Π΄Π°Π½Π½Ρ‹Π΅"

        trades = result["data"]
        closed = [t for t in trades if int(t.get("close_time", 0)) > 0]
        open_trades = [t for t in trades if int(t.get("close_time", 0)) == 0]

        # Group by strategy tag
        strats = {"WT": [], "Scalp": [], "Gerchik": [], "Untagged": []}
        gerchik_models = {}  # model_tag -> count

        for t in closed:
            tags = t.get("tags") or []
            tag_names = [tag["name"] for tag in tags]

            assigned = False
            for strat_key in ["WT", "Scalp", "Gerchik"]:
                if strat_key in tag_names:
                    strats[strat_key].append(t)
                    assigned = True
                    break
            if not assigned:
                strats["Untagged"].append(t)

            # Count Gerchik models
            for tn in tag_names:
                if tn in ("ΠžΡ‚Π±ΠΎΠΉ ΠΎΡ‚ уровня", "Π›ΠΎΠΆΠ½Ρ‹ΠΉ ΠΏΡ€ΠΎΠ±ΠΎΠΉ", "Π‘Π»ΠΎΠΆΠ½Ρ‹ΠΉ Π»ΠΎΠΆΠ½Ρ‹ΠΉ ΠΏΡ€ΠΎΠ±ΠΎΠΉ", "ΠŸΡ€ΠΎΠ±ΠΎΠΉ уровня"):
                    gerchik_models[tn] = gerchik_models.get(tn, 0) + 1

        lines = ["πŸ’° PnL Summary (TMM)", "━━━━━━━━━━━━━━━━━━━━"]

        total_pnl = 0
        for name, emoji in [("WT", "πŸ“Š"), ("Scalp", "⚑"), ("Gerchik", "πŸ“")]:
            group = strats[name]
            if not group:
                continue
            cnt = len(group)
            pnl = sum(float(t.get("net_profit", 0)) for t in group)
            total_pnl += pnl
            wins = sum(1 for t in group if float(t.get("net_profit", 0)) > 0)
            losses = cnt - wins
            wr = (wins / cnt * 100) if cnt > 0 else 0

            lines.append(f"\n{emoji} {name}:")
            lines.append(f"  Π‘Π΄Π΅Π»ΠΎΠΊ: {cnt} ({wins}W/{losses}L)")
            lines.append(f"  WR: {wr:.0f}%")
            lines.append(f"  πŸ’΅ PnL: ${pnl:+.2f}")

            if name == "Gerchik" and gerchik_models:
                models_str = " | ".join(f"{k}: {v}" for k, v in gerchik_models.items())
                lines.append(f"  πŸ“‹ {models_str}")

        # Untagged β€” don't include in total (pre-integration trades)
        if strats["Untagged"]:
            lines.append(f"\n❓ Π‘Π΅Π· Ρ‚Π΅Π³Π°: {len(strats['Untagged'])} (Π΄ΠΎ ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ)")

        if open_trades:
            lines.append(f"\n⏳ ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΠΎ: {len(open_trades)}")

        lines.append(f"\nπŸ’° TOTAL: ${total_pnl:+.2f}")
        lines.append("━━━━━━━━━━━━━━━━━━━━")

        return "\n".join(lines)

    _pending_tags: list = []

πŸ“œ Git History

c6f6bd5chore: initial commit β€” version control setup5 weeks ago
Show last diff
Loading...