File size: 15,605 Bytes
0ed5d3d
 
 
100f582
6dc12fb
 
 
0ed5d3d
6dc12fb
 
0ed5d3d
6dc12fb
 
0ed5d3d
 
 
 
6dc12fb
0ed5d3d
 
6dc12fb
 
 
 
 
08536d7
6dc12fb
 
 
 
 
 
 
 
 
0ed5d3d
6dc12fb
 
 
 
 
 
 
 
 
 
 
08536d7
6dc12fb
 
0ed5d3d
e93ef16
0ed5d3d
6dc12fb
 
 
08536d7
6dc12fb
 
 
 
0ed5d3d
6dc12fb
 
 
08536d7
6dc12fb
 
 
 
0ed5d3d
 
 
 
 
 
 
 
 
 
 
 
6dc12fb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ed5d3d
6dc12fb
 
 
 
 
 
 
 
 
 
 
 
0ed5d3d
 
 
 
 
6dc12fb
0ed5d3d
 
 
6dc12fb
 
71dc075
6dc12fb
71dc075
 
 
 
 
 
 
 
 
 
 
859578b
71dc075
 
6dc12fb
71dc075
 
 
6dc12fb
 
 
 
 
 
 
 
 
0ed5d3d
 
 
 
 
6dc12fb
0ed5d3d
 
6dc12fb
0ed5d3d
 
6dc12fb
 
 
0ed5d3d
6dc12fb
 
0ed5d3d
 
6dc12fb
 
0ed5d3d
6dc12fb
 
 
 
 
 
 
defd808
661c09a
6dc12fb
 
 
 
 
 
 
 
 
661c09a
6dc12fb
 
 
523ce92
0ed5d3d
 
 
6dc12fb
 
0ed5d3d
 
 
 
 
6dc12fb
0ed5d3d
 
 
 
 
6dc12fb
0ed5d3d
6dc12fb
5a86ef2
0ed5d3d
 
 
 
 
 
6dc12fb
 
0ed5d3d
6dc12fb
 
 
 
 
523ce92
 
 
 
 
6dc12fb
0ed5d3d
 
6dc12fb
0ed5d3d
6dc12fb
 
0ed5d3d
 
 
 
 
6dc12fb
0ed5d3d
 
 
 
 
 
 
6dc12fb
 
0ed5d3d
 
 
 
 
6dc12fb
0ed5d3d
6dc12fb
0ed5d3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6dc12fb
 
 
 
 
 
 
0ed5d3d
9545d43
0ed5d3d
6dc12fb
 
0ed5d3d
 
 
 
6dc12fb
0ed5d3d
 
6dc12fb
 
 
 
0ed5d3d
 
 
 
 
 
 
 
 
 
 
 
9545d43
0ed5d3d
 
 
 
6dc12fb
0ed5d3d
6dc12fb
0ed5d3d
6dc12fb
0ed5d3d
6dc12fb
0ed5d3d
 
 
 
 
 
 
 
 
 
 
 
 
 
6dc12fb
 
0ed5d3d
9915a12
0ed5d3d
6dc12fb
 
0ed5d3d
 
 
 
6dc12fb
0ed5d3d
 
 
 
 
 
 
 
6dc12fb
0ed5d3d
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
import os
import asyncio
import importlib
import json
import tempfile
import traceback
from datetime import datetime, timedelta, timezone

import gradio as gr
import pandas as pd
from dotenv import load_dotenv

import hype_accounts_server  # your trading MCP
from agents import Agent, Runner, trace, Tool
from agents.mcp import MCPServerStdio
from memory_utils import load_memories, save_memory, load_memories_df

# ====================== CONFIG ======================

load_dotenv(override=True)

MODEL = os.getenv("OPENAI_MODEL", "gpt-5-mini")  # fallback if not set

RUN_INTERVAL_MINS = int(os.getenv("RUN_INTERVAL_MINS", "60"))
HEARTBEAT_SECS = int(os.getenv("HEARTBEAT_SECS", "5"))
MAX_RUN_SECS = int(os.getenv("MAX_RUN_SECS", "500"))

CSV_PATH = os.path.join(tempfile.gettempdir(), "memories_latest.csv")

# If you use server2, change this to 'hl_indicators_server2.py'
TA_SERVER_SCRIPT = os.getenv("TA_SERVER_SCRIPT", "hl_indicators_server.py")
NEWS_SERVER_SCRIPT = os.getenv("NEWS_SERVER_SCRIPT", "crypto_news_scraper_server.py")

# ====================== MCP SERVER FACTORIES ======================

def make_hyperliquid_trader_mcp_servers():
    return [
        MCPServerStdio(
            {
                "command": "python3",
                "args": ["-u", "hype_accounts_server.py"],
                "env": {
                    "HYPERLIQUID_API_KEY": os.getenv("HYPERLIQUID_API_KEY"),
                    "HYPERLIQUID_PRIVATE_KEY": os.getenv("HYPERLIQUID_PRIVATE_KEY"),
                    "HYPERLIQUID_ACCOUNT_ADDRESS": os.getenv("HYPERLIQUID_ACCOUNT_ADDRESS"),
                },
            },
            client_session_timeout_seconds=60,
        )
    ]


def make_crypto_news_mcp_servers():
    return [
        MCPServerStdio(
            {"command": "python3", "args": ["-u", NEWS_SERVER_SCRIPT]},
            client_session_timeout_seconds=60,
        )
    ]


def make_technical_analyst_mcp_servers():
    return [
        MCPServerStdio(
            {"command": "python3", "args": ["-u", TA_SERVER_SCRIPT]},
            client_session_timeout_seconds=60,
        )
    ]

# ====================== UTILITIES ======================

async def connect_all(servers):
    for s in servers:
        await s.connect()

async def close_all(servers):
    for s in servers:
        try:
            await s.close()
        except Exception:
            pass

def now() -> datetime:
    """Return current UTC time (aware)."""
    return datetime.now(timezone.utc)

def next_run_dt(interval_mins: int = RUN_INTERVAL_MINS) -> datetime:
    """
    Return the next run time, aligned to interval_mins in UTC.
    Example: if now=10:07 UTC and interval=15 β†’ 10:15 UTC.
    """
    t = now().replace(microsecond=0)
    n = int(interval_mins)
    if n <= 0:
        raise ValueError("interval_mins must be > 0")
    step = n * 60
    ts = int(t.timestamp())
    next_ts = ts - (ts % step) + step
    return datetime.fromtimestamp(next_ts, tz=timezone.utc)

def eta_str(target: datetime) -> str:
    """Return time difference between now() and target as 'MM:SS' (UTC)."""
    secs = max(0, int((target - now()).total_seconds()))
    m, s = divmod(secs, 60)
    return f"{m:02d}:{s:02d}"

def write_mem_csv(df):
    if df is None or df.empty:
        df = pd.DataFrame(columns=["ts", "summary"])
    df.to_csv(CSV_PATH, index=False)
    return CSV_PATH

# ====================== RESEARCHER AGENTS ======================

async def get_crypto_news_researcher(mcp_servers) -> Agent:
    instructions = (
        "You are a cryptocurrency researcher. You can call MCP tools to gather news and surface "
        "potential opportunities. If the user (or prompt) specifies coins (e.g., HYPE, BTC, ETH, XRP), "
        "focus on those; otherwise scan broadly. Be concise and actionable.\n"
        f"Time (UTC): {now().strftime('%Y-%m-%d %H:%M:%S')}"
    )
    return Agent(
        name="Crypto news researcher",
        instructions=instructions,
        model=MODEL,
        mcp_servers=mcp_servers,
    )

async def get_crypto_news_researcher_tool(mcp_servers) -> Tool:
    researcher = await get_crypto_news_researcher(mcp_servers)
    return researcher.as_tool(
        tool_name="crypto_news_researcher",
        tool_description="Research crypto news and summarize opportunities (broad or coin-specific).",
    )

async def get_technical_analyst_researcher(mcp_servers) -> Agent:
    # Generic; LLM decides which TA tools to call and in what order.
    instructions = (
        "Role: Cryptocurrency technical researcher using MCP indicator tools.\n"
        "Goals:\n"
        "- Form a directional bias (long/short/neutral) for each requested coin/timeframe.\n"
        "- Summarize key trend/momentum/volatility/volume signals with latest values.\n"
        "- Optionally outline a trade *idea skeleton*, without enforcing a fixed strategy.\n"
        "Reasoning Hints (non-prescriptive):\n"
        "- Trend (MA/EMA alignment, slope); Momentum (RSI/MACD); Volatility (ATR, squeezes); Volume/Flow (OBV/ADL/VWAP context); Strength (ADX).\n"
        "- Call out divergences, regime shifts, chop (low ADX), or news-led anomalies.\n"
        "- Keep caveats explicit (thin liquidity, event risk, conflicting signals).\n"
        "Data Discipline:\n"
        "- Use only completed candles (exclude_current_bar=True).\n"
        "- Default params unless caller overrides:\n"
        "  interval='1h', lookback_period=6, limit=600, include_signals=True, exclude_current_bar=True, return_last_only=False.\n"
        "- When calling tools, pass only the coin symbol as `name` (eg. BTC, ETH, SOL, XRP, SUI, etc.). No exchange prefixes/suffixes or quote assets.\n"
        "\n"
        "Output (per coin)\n"
        "1) One-line bias + confidence.\n"
        "2) Bullet signals with latest values and reasoning.\n"
        "3) Optional trade idea.\n"
        "4) Caveats. \n\n"
        "\n"
        f"Time (UTC): {now().strftime('%Y-%m-%d %H:%M:%S')}"
    )
    return Agent(
        name="Crypto technical researcher",
        instructions=instructions,
        model=MODEL,
        mcp_servers=mcp_servers,
    )

async def get_technical_analyst_researcher_tool(mcp_servers) -> Tool:
    researcher = await get_technical_analyst_researcher(mcp_servers)
    return researcher.as_tool(
        tool_name="crypto_technical_researcher",
        tool_description="Analyze TA indicators and propose trade ideas (coin/interval/lookback optional).",
    )

# ====================== TRADER CORE ======================

async def run_trader():
    hyper_servers = []
    news_servers = []
    ta_servers = []
    try:
        print(f"\nπŸ•’ Starting trading cycle at UTC {now().strftime('%Y-%m-%d %H:%M:%S')}")
        # Memory (optional context)
        past_memories = load_memories(1)
        memory_text = "\n".join(past_memories) if past_memories else "No prior memories."

        # Account (assumed async in your hype_accounts_server)
        account_details = await hype_accounts_server.get_account_details()
        pretty_details = json.dumps(account_details, indent=2) if isinstance(account_details, (dict, list)) else str(account_details)

        trader_instructions = (
            "You are a cryptocurrency perpetuals trader.\n"
            f"Past insights:\n{memory_text}\n\n"
            f"Current holdings/balance:\n{pretty_details}\n\n"
            "You have tools for news, hourly technical indicators, holdings/balance, and Hyperliquid execution.\n"
            "When scanning for crypto news, scan broadly for any oppotunities."
            "When calling technical indicator tools, pass only the coin symbol as `name` (eg. BTC, ETH, SOL, XRP, SUI, HYPE, ZEC, etc.) Do NOT include exchange prefixes/suffixes or quote assets (no BINANCE:, no /USDT, no -PERP, no .PERP).\n"
            "Carry out trades as you see fit β€” take profits and cut losses as needed."
            "Do not wait for instructions or ask for confirmation."
            "Each transaction costs 0.04%.\n"
            "If signals are unclear, do not trade."
        )

        # High-level prompt to drive the agent plan
        prompt = (
            "Step 1 β€” Check holdings & balance.\n"
            "Step 2 β€” Scan crypto news broadly for ANY potential coin trading opportunities. Be open minded. Do not limit to a list.\n"
            "Step 3 β€” Select 3–5 symbols based on news & holdings. Analyze 1h indicators and propose strategies.\n"
            "Step 4 β€” Execute long/short only if edge exists; else abstain.\n"
            f"Now (UTC): {now().strftime('%Y-%m-%d %H:%M')}"
        )

        # Fresh servers each run
        hyper_servers = make_hyperliquid_trader_mcp_servers()
        news_servers = make_crypto_news_mcp_servers()
        ta_servers = make_technical_analyst_mcp_servers()

        await connect_all(hyper_servers)
        await connect_all(news_servers)
        await connect_all(ta_servers)

        # Bind tools to their respective servers
        crypto_news_tool = await get_crypto_news_researcher_tool(news_servers)
        ta_tool = await get_technical_analyst_researcher_tool(ta_servers)

        trader = Agent(
            name="crypto_trader",
            instructions=trader_instructions,
            tools=[crypto_news_tool, ta_tool],
            mcp_servers=hyper_servers,  # trading actions live here
            model=MODEL,
        )

        async def _run():
            with trace("crypto_trader"):
                return await Runner.run(trader, prompt, max_turns=30)

        # result = await asyncio.wait_for(_run(), timeout=MAX_RUN_SECS)
        result = await _run()
        save_memory(result.final_output)

        mem_df = load_memories_df(10) if past_memories else pd.DataFrame(columns=["ts", "summary"])

        output_md = (
            f"### βœ… Trade Completed UTC {now().strftime('%Y-%m-%d %H:%M')}\n\n"
            f"{result.final_output}\n\n"
            f"---\n\n"
            f"### πŸ’³ Account Details (Post-Run)\n"
            f"```\n{pretty_details}\n```"
        )
        return output_md, mem_df

    except asyncio.TimeoutError:
        return "❌ Timed out (300s). Consider raising timeout or reducing tool work per cycle.", pd.DataFrame(columns=["ts", "summary"])
    except Exception as e:
        err = traceback.format_exc()
        return f"❌ Error during trading cycle:\n```\n{err}\n```", pd.DataFrame(columns=["ts", "summary"])
    finally:
        await close_all(hyper_servers)
        await close_all(news_servers)
        await close_all(ta_servers)

# ====================== SCHEDULER / UI STATE ======================

LATEST_OUTPUT = "⏳ Waiting for first scheduled run…"
LATEST_MEM_DF = pd.DataFrame(columns=["ts", "summary"])
NEXT_RUN_AT = None
IS_RUNNING = False
LAST_RUN_STARTED_AT = None
LAST_RUN_ENDED_AT = None

STOP_TRADING = False

_STATE_LOCK = asyncio.Lock()
_SCHEDULER_STARTED = False
_RUN_LOCK = asyncio.Lock()

async def safe_run_trader(force: bool = False):
    global LATEST_OUTPUT, LATEST_MEM_DF, IS_RUNNING, LAST_RUN_STARTED_AT, LAST_RUN_ENDED_AT
    if _RUN_LOCK.locked() or (STOP_TRADING and not force):
        return

    async with _RUN_LOCK:
        IS_RUNNING = True
        LAST_RUN_STARTED_AT = now()
        try:
            md, mem_df = await asyncio.wait_for(run_trader(), timeout=MAX_RUN_SECS)
            async with _STATE_LOCK:
                LATEST_OUTPUT = md or "(no output)"
                if isinstance(mem_df, pd.DataFrame) and not mem_df.empty:
                    LATEST_MEM_DF = mem_df.copy()
                else:
                    stamp = now().strftime("%Y-%m-%d %H:%M:%S")
                    extra = pd.DataFrame([{"ts": stamp, "summary": "βœ… Run completed"}])
                    LATEST_MEM_DF = pd.concat([LATEST_MEM_DF, extra], ignore_index=True)
        except Exception:
            err = traceback.format_exc()
            async with _STATE_LOCK:
                LATEST_OUTPUT = f"❌ Exception\n```\n{err}\n```"
        finally:
            LAST_RUN_ENDED_AT = now()
            IS_RUNNING = False

# --- Manual "Run Now" trigger (async) ---
async def on_run_now():
    # Force a run even if paused; lock prevents overlap
    await safe_run_trader(force=True)
    # Return the same tuple your timer updates, so the UI refreshes immediately
    latest, status, mem_df, csv_path = await ui_poll()
    return latest, status, mem_df, csv_path

async def run_scheduler():
    global NEXT_RUN_AT
    NEXT_RUN_AT = next_run_dt(RUN_INTERVAL_MINS)
    # sleep until next aligned tick
    await asyncio.sleep(max(0.0, (NEXT_RUN_AT - now()).total_seconds()))
    while True:
        if not STOP_TRADING:
            await safe_run_trader()
            NEXT_RUN_AT = next_run_dt(RUN_INTERVAL_MINS)
        else:
            NEXT_RUN_AT = None
        # sleep towards the next run or heartbeat-period while paused
        await asyncio.sleep(
            HEARTBEAT_SECS if STOP_TRADING else max(0.0, (NEXT_RUN_AT - now()).total_seconds())
        )

async def heartbeat():
    while True:
        state = "⏸️ Paused" if STOP_TRADING else ("🟒 Running" if IS_RUNNING else "🟑 Idle")
        print(f"[{now():%H:%M:%S}] πŸ’“ {state}")
        await asyncio.sleep(HEARTBEAT_SECS)

async def ensure_scheduler_started():
    global _SCHEDULER_STARTED
    if not _SCHEDULER_STARTED:
        _SCHEDULER_STARTED = True
        asyncio.create_task(heartbeat())
        asyncio.create_task(run_scheduler())

def status_markdown():
    lines = [f"**Status:** {'⏸️ Paused' if STOP_TRADING else ('🟒 Running' if IS_RUNNING else '🟑 Idle')}"]
    if LAST_RUN_STARTED_AT:
        lines.append(f"**Last start (UTC):** {LAST_RUN_STARTED_AT:%Y-%m-%d %H:%M:%S}")
    if LAST_RUN_ENDED_AT:
        lines.append(f"**Last end (UTC):** {LAST_RUN_ENDED_AT:%Y-%m-%d %H:%M:%S}")
    if NEXT_RUN_AT and not STOP_TRADING:
        lines.append(f"**Next run (UTC):** {NEXT_RUN_AT:%Y-%m-%d %H:%M} (ETA {eta_str(NEXT_RUN_AT)})")
    if STOP_TRADING:
        lines.append("**Scheduler:** waiting for Resume")
    return "\n\n".join(lines)

async def ui_poll():
    async with _STATE_LOCK:
        latest = LATEST_OUTPUT
        mem_df = LATEST_MEM_DF.copy()
    csv_path = write_mem_csv(mem_df)
    return latest, status_markdown(), mem_df, csv_path

def _toggle_stop(flag: bool):
    global STOP_TRADING
    STOP_TRADING = flag
    return status_markdown()

# ====================== GRADIO UI ======================

with gr.Blocks(fill_height=True) as demo:
    gr.Markdown("## πŸ“ˆ GPT Crypto Trader β€” Live Board")
    gr.Markdown(
        "[agent tracer](https://platform.openai.com/logs?api=traces)  \n"
        "[hyperliquid platform](https://app.hyperliquid.xyz/trade)"
    )
    with gr.Row():
        stop_btn = gr.Button("πŸ›‘ Stop", variant="stop")
        resume_btn = gr.Button("▢️ Resume", variant="primary")
        run_now_btn = gr.Button("⚑ Run Now", variant="secondary")

    latest_md = gr.Markdown(value=LATEST_OUTPUT)
    status_md = gr.Markdown(value=status_markdown())
    mem_table = gr.Dataframe(value=LATEST_MEM_DF, interactive=False, wrap=True, show_label=False)
    mem_csv = gr.File(interactive=False)

    stop_btn.click(lambda: _toggle_stop(True), outputs=[status_md])
    resume_btn.click(lambda: _toggle_stop(False), outputs=[status_md])
    run_now_btn.click(on_run_now, outputs=[latest_md, status_md, mem_table, mem_csv])

    demo.load(ensure_scheduler_started)
    timer = gr.Timer(5.0, active=True)
    timer.tick(ui_poll, outputs=[latest_md, status_md, mem_table, mem_csv])

if __name__ == "__main__":
    demo.queue().launch(share=True)