Skip to content
Open
100 changes: 98 additions & 2 deletions agents/meteora_launch_lp/routines/damm_v2_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
The scanner deliberately skips pools with an active fee scheduler (base fee often starts near 99%
and decays — a token-launch trap) unless include_launch_pools is set.
"""
import io
import logging

import aiohttp
Expand All @@ -34,6 +35,66 @@
"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB",
}
_WINDOWS = {"1h", "2h", "4h", "12h", "24h"}
_WINDOW_HOURS = {"1h": 1, "2h": 2, "4h": 4, "12h": 12, "24h": 24}


def _apr(fee_yield: float, window: str) -> float:
"""Annualize a window fee yield into a fee APR %.

The Meteora API's fee_tvl_ratio is ALREADY a percentage per window
(verified: fees.24h / tvl × 100 == fee_tvl_ratio.24h), so only scale by
windows-per-year.
"""
return fee_yield * (8760 / _WINDOW_HOURS.get(window, 24))


def _build_chart(ranked: list[dict], window: str) -> tuple[bytes | None, object]:
"""Horizontal fee-APR bar chart of the ranked pools (PNG bytes + plotly fig)."""
try:
import plotly.graph_objects as go
except ImportError:
return None, None

pools = list(reversed(ranked))
n = len(pools)
labels, texts, vals, colors = [], [], [], []
for i, c in enumerate(pools):
rank = n - i
detail = (f"TVL ${c['tvl']:,.0f} · vol {window} ${c['vol_win']:,.0f} · "
f"fee {c['base_fee_pct']:.2f}%")
labels.append(f"#{rank} {c['pair']}<br>"
f"<span style='color:#8b949e;font-size:9px'>{detail}</span>")
apr = _apr(c["fee_yield"], window)
vals.append(apr)
texts.append(f"{apr:,.0f}%")
colors.append("#d4a017" if rank <= 3 else "#8664c6")

fig = go.Figure(go.Bar(
y=labels, x=vals, orientation="h",
marker=dict(color=colors, line=dict(width=0)),
text=texts, textposition="outside",
textfont=dict(size=11, color="#ffffff", family="monospace"),
hovertemplate="<b>%{y}</b><br>Fee APR: %{text}<extra></extra>",
showlegend=False,
))
fig.update_layout(
title=dict(text=f"Meteora DAMM v2 — Fee APR (annualized from {window} fees/TVL)",
font=dict(size=15, color="#ffffff"), x=0.5, xanchor="center"),
height=max(450, n * 52 + 130), width=1100,
paper_bgcolor="#1a1a2e", plot_bgcolor="#1a1a2e",
font=dict(size=11, color="#e0e0e0"),
margin=dict(l=300, r=90, t=70, b=40), bargap=0.3,
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False,
range=[0, (max(vals) if vals else 1) * 1.18]),
yaxis=dict(showgrid=False, tickfont=dict(color="#e0e0e0", size=11)),
)
try:
buf = io.BytesIO()
fig.write_image(buf, format="png", scale=2)
return buf.getvalue(), fig
except Exception as e:
logger.warning(f"damm_v2_scanner: PNG export failed: {e}")
return None, fig


class Config(BaseModel):
Expand Down Expand Up @@ -127,7 +188,7 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str:
rows.append({
"#": i,
"Pair": c["pair"],
"FeeYield": f"{c['fee_yield'] * 100:.3f}%",
"FeeYield": f"{c['fee_yield']:.3f}%",
"BaseFee": f"{c['base_fee_pct']:.3f}%",
"TVL": f"${c['tvl']:,.0f}",
f"Vol{window}": f"${c['vol_win']:,.0f}",
Expand All @@ -148,9 +209,44 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str:
f"{'included' if config.include_launch_pools else 'excluded'}."
)

chart_bytes, plotly_fig = _build_chart(ranked, window)

chat_id = getattr(context, "_chat_id", None)
if chart_bytes and chat_id and context.bot:
try:
await context.bot.send_photo(
chat_id=chat_id, photo=io.BytesIO(chart_bytes),
caption=f"DAMM v2 fee-APR ranking — top {len(ranked)} {quote}-quoted pools",
)
except Exception as e:
logger.warning(f"damm_v2_scanner: failed to send chart to Telegram: {e}")

try:
from condor.reports import ReportBuilder

builder = ReportBuilder("DAMM v2 Fee-Yield Scanner")
builder.source("routine", "damm_v2_scanner").tags(["meteora", "damm-v2", "lp", "yield"])
builder.kpi("Pools ranked", str(len(ranked)))
builder.kpi("Top fee APR", f"{_apr(ranked[0]['fee_yield'], window):,.0f}%")
builder.kpi("Top pool TVL", f"${ranked[0]['tvl']:,.0f}")
builder.markdown(
f"{quote}-quoted Meteora DAMM v2 pools ranked by {window} fees/TVL "
f"({len(candidates)} candidates passed filters; min TVL ${config.min_tvl_usd:,.0f}, "
f"verified_only={config.verified_only}, launch pools "
f"{'included' if config.include_launch_pools else 'excluded'})."
)
if plotly_fig is not None:
builder.plotly(plotly_fig)
builder.table(rows)
builder.manual_order()
await builder.save()
except Exception as e:
logger.warning(f"damm_v2_scanner: report save failed: {e}")

try:
from routines.base import RoutineResult
return RoutineResult(text=summary, table_data=rows, table_columns=columns)
return RoutineResult(text=summary, table_data=rows, table_columns=columns,
chart_image=chart_bytes)
except Exception:
lines = [summary, ""]
for r in rows:
Expand Down
131 changes: 129 additions & 2 deletions agents/meteora_launch_lp/routines/easya_graduation_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@
Note: early LP here is directional-long the token (you must buy the base token to pair it with SOL),
so size small and capped.
"""
import io
import logging
import time
from datetime import datetime, timezone

import aiohttp
from pydantic import BaseModel, Field
Expand All @@ -32,6 +34,92 @@
EASYA_MINT_SUFFIX = "EASY" # EasyA vanity-suffix marker on graduated token mints


GECKO_OHLCV = "https://api.geckoterminal.com/api/v2/networks/solana/pools/{pool}/ohlcv/hour"


async def _fetch_ohlcv(session: aiohttp.ClientSession, pool: str, hours: float) -> tuple[list[dict], str]:
"""Hourly OHLCV bars covering the pool's whole post-graduation life.

Primary: GeckoTerminal (up to 1000 hourly bars, price in USD). Fallback: the Meteora
DAMM v2 data API's native OHLCV (price in SOL), which caps at ~10 bars per response,
so a coarse timeframe is chosen to still span the pool's age.
Returns (bars, price_unit) where bars are {timestamp, open, high, low, close, volume}.
"""
limit = max(12, min(int(hours) + 4, 1000))
try:
async with session.get(GECKO_OHLCV.format(pool=pool), params={"limit": limit},
timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 200:
ol = (await resp.json())["data"]["attributes"]["ohlcv_list"]
if ol:
bars = [{"timestamp": b[0], "open": b[1], "high": b[2], "low": b[3],
"close": b[4], "volume": b[5]} for b in reversed(ol)]
return bars, "USD"
except Exception as e:
logger.warning(f"easya_graduation_monitor: GeckoTerminal OHLCV failed for {pool}: {e}")

# Fallback: Meteora native (≈10 bars max) — pick a timeframe wide enough to span the age.
timeframe = "1h" if hours <= 10 else "2h" if hours <= 20 else "4h" if hours <= 40 else "12h"
try:
async with session.get(f"{DAMM_V2_API}/{pool}/ohlcv", params={"timeframe": timeframe},
timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
return [], ""
return (await resp.json()).get("data", []), "SOL"
except Exception as e:
logger.warning(f"easya_graduation_monitor: Meteora OHLCV failed for {pool}: {e}")
return [], ""


def _build_chart(top: dict, bars: list[dict], price_unit: str = "SOL") -> tuple[bytes | None, object]:
"""Price + volume panel for the top graduation — shows the post-graduation
dump/stabilization path the agent must wait out before LPing."""
if not bars:
return None, None
try:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
except ImportError:
return None, None

ts = [datetime.fromtimestamp(b["timestamp"], tz=timezone.utc) for b in bars]
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
row_heights=[0.72, 0.28], vertical_spacing=0.04)
fig.add_trace(go.Candlestick(
x=ts, open=[b["open"] for b in bars], high=[b["high"] for b in bars],
low=[b["low"] for b in bars], close=[b["close"] for b in bars],
increasing_line_color="#26a69a", decreasing_line_color="#ef5350",
name=f"Price ({price_unit})", showlegend=False,
), row=1, col=1)
fig.add_trace(go.Bar(
x=ts, y=[b["volume"] for b in bars],
marker_color="#8664c6", name="Volume", showlegend=False,
), row=2, col=1)
fig.update_layout(
title=dict(
text=(f"{top['pair']} — post-graduation price path "
f"(age {top['age_h']:.0f}h · TVL ${top['tvl']:,.0f} · "
f"fee yield 24h {top['fee_yield24h']:.2f}%)"),
font=dict(size=14, color="#ffffff"), x=0.5, xanchor="center"),
height=560, width=1100,
paper_bgcolor="#1a1a2e", plot_bgcolor="#1a1a2e",
font=dict(size=11, color="#e0e0e0"),
margin=dict(l=70, r=30, t=70, b=40),
xaxis_rangeslider_visible=False,
yaxis=dict(title=f"Price ({price_unit})", showgrid=True, gridcolor="#2a2a3e",
tickformat=".2e"),
yaxis2=dict(title=f"Vol ({price_unit})", showgrid=False),
xaxis2=dict(showgrid=False),
)
try:
buf = io.BytesIO()
fig.write_image(buf, format="png", scale=2)
return buf.getvalue(), fig
except Exception as e:
logger.warning(f"easya_graduation_monitor: PNG export failed: {e}")
return None, fig


class Config(BaseModel):
"""Detect fresh EasyA graduations into Meteora DAMM v2, ranked by fee yield."""

Expand Down Expand Up @@ -119,7 +207,7 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str:
"#": i,
"Pair": c["pair"],
"Age(h)": f"{c['age_h']:.1f}",
"FeeYield": f"{c['fee_yield24h'] * 100:.1f}%",
"FeeYield": f"{c['fee_yield24h']:.2f}%",
"TVL": f"${c['tvl']:,.0f}",
"Vol24h": f"${c['vol24h']:,.0f}",
"Verified": "yes" if c["verified"] else "no",
Expand All @@ -139,9 +227,48 @@ async def run(config: Config, context: ContextTypes.DEFAULT_TYPE) -> str:
f"capped size (directional-long the token). Journal the position_address."
)

# Price/volume panel for the top graduation — the visual gate for "has the dump cleared?"
top_c = ranked[0]
async with aiohttp.ClientSession() as session:
bars, price_unit = await _fetch_ohlcv(session, top_c["pool"], top_c["age_h"])
chart_bytes, plotly_fig = _build_chart(top_c, bars, price_unit)

chat_id = getattr(context, "_chat_id", None)
if chart_bytes and chat_id and context.bot:
try:
await context.bot.send_photo(
chat_id=chat_id, photo=io.BytesIO(chart_bytes),
caption=f"{top_c['pair']} — post-graduation price path ({top_c['age_h']:.0f}h)",
)
except Exception as e:
logger.warning(f"easya_graduation_monitor: failed to send chart to Telegram: {e}")

try:
from condor.reports import ReportBuilder

builder = ReportBuilder("EasyA Graduation Monitor")
builder.source("routine", "easya_graduation_monitor").tags(
["meteora", "damm-v2", "easya", "launch"])
builder.kpi("Graduations found", str(len(ranked)))
builder.kpi("Top fee yield 24h", top["FeeYield"])
builder.kpi("Top age", f"{top['Age(h)']}h")
builder.markdown(
f"EasyA graduations into Meteora DAMM v2 in the last {config.max_age_hours:.0f}h "
f"(min TVL ${config.min_tvl_usd:,.0f}, min vol24h ${config.min_vol24h_usd:,.0f}). "
f"Gate each on sellability, safety, and post-dump demand before LPing."
)
if plotly_fig is not None:
builder.plotly(plotly_fig)
builder.table(rows)
builder.manual_order()
await builder.save()
except Exception as e:
logger.warning(f"easya_graduation_monitor: report save failed: {e}")

try:
from routines.base import RoutineResult
return RoutineResult(text=summary, table_data=rows, table_columns=columns)
return RoutineResult(text=summary, table_data=rows, table_columns=columns,
chart_image=chart_bytes)
except Exception:
lines = [summary, ""]
for r in rows:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ For each open position, read its current value and decide exit. Value it in SOL:
percentage_to_remove=100)`** if ANY fires:
- `pnl_pct ≥ take_profit_pct` (TP) or `pnl_pct ≤ −stop_loss_pct` (SL);
- **fee-APR decay**: pool `fee_tvl_ratio.24h` annualized < `min_fee_apr_pct` (the fee flow that
justified the IL exposure has dried up — from `easya_graduation_monitor`/the Meteora API);
justified the IL exposure has dried up — from `easya_graduation_monitor`/the Meteora API).
UNITS: `fee_tvl_ratio.24h` is ALREADY a percent per 24h (5.22 = 5.22%/day) — annualize as
`× 365` only, never `× 365 × 100`;
- **drawdown**: token price down ≥ `drawdown_pct` from your entry price;
- **max hold**: position age ≥ `max_hold_hours`;
- **honeypot regression**: a fresh sellability quote (skill gate 1) now fails → exit at any price you
Expand Down
10 changes: 8 additions & 2 deletions condor/web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ class UpdateServerRequest(BaseModel):
class GatewayStartRequest(BaseModel):
# The Hummingbot API always runs the Gateway secured (TLS + mTLS) and manages the
# certificates/passphrase itself (hummingbot-api SEC-048), so only image/port are sent.
image: str = "hummingbot/gateway:latest"
image: str = "hummingbot/gateway:development"
port: int = 15888


Expand All @@ -506,7 +506,13 @@ class CredentialInfo(BaseModel):


class GatewayPullRequest(BaseModel):
image: str = "hummingbot/gateway:latest"
image: str = "hummingbot/gateway:development"


class AddGatewayWalletRequest(BaseModel):
chain: str
private_key: str
set_default: bool = True


class AddCredentialRequest(BaseModel):
Expand Down
Loading