);
}
diff --git a/frontend/src/components/settings/ConnectHyperliquid.tsx b/frontend/src/components/settings/ConnectHyperliquid.tsx
index 82b980502..e91c28a2f 100644
--- a/frontend/src/components/settings/ConnectHyperliquid.tsx
+++ b/frontend/src/components/settings/ConnectHyperliquid.tsx
@@ -147,7 +147,7 @@ export function ConnectHyperliquid({
const reason = (failed[0].result as PromiseRejectedResult).reason as { message?: string };
setPartial(
`Connected, but ${failed.map((f) => f.name).join(", ")} could not be saved ` +
- `(${reason?.message || "validation failed"}). Retry it from the API Keys list — no re-signing needed.`,
+ `(${reason?.message || "validation failed"}). Retry it from the Keys and Wallets list — no re-signing needed.`,
);
}
diff --git a/frontend/src/components/settings/ImportGatewayWallet.tsx b/frontend/src/components/settings/ImportGatewayWallet.tsx
new file mode 100644
index 000000000..eff422b21
--- /dev/null
+++ b/frontend/src/components/settings/ImportGatewayWallet.tsx
@@ -0,0 +1,198 @@
+import { AlertCircle, ArrowLeft, Check, Loader2, ShieldAlert } from "lucide-react";
+import { useMemo, useState } from "react";
+import { privateKeyToAccount } from "viem/accounts";
+
+import { api } from "@/lib/api";
+
+export type WalletChain = "solana" | "ethereum";
+
+const CHAIN_LABEL: Record = {
+ solana: "Solana",
+ ethereum: "Ethereum",
+};
+
+// Browser wallets (Phantom, MetaMask, Rabby) never expose private keys to websites — that's by
+// design, and why this flow is a manual export + paste instead of a one-click "Connect".
+const EXPORT_INSTRUCTIONS: Record = {
+ solana: [
+ {
+ wallet: "Phantom",
+ steps: "Settings → Manage Accounts → select account → Show Private Key",
+ },
+ ],
+ ethereum: [
+ { wallet: "MetaMask", steps: "⋮ menu → Account details → Show private key" },
+ { wallet: "Rabby", steps: "Click address → Address Detail → Backup Private Key" },
+ ],
+};
+
+const EVM_KEY_RE = /^(0x)?[0-9a-fA-F]{64}$/;
+// Phantom exports the keypair as base58, typically 87–88 chars.
+const SOLANA_KEY_RE = /^[1-9A-HJ-NP-Za-km-z]{64,120}$/;
+
+export function ImportGatewayWallet({
+ server,
+ chain,
+ onBack,
+ onDone,
+}: {
+ server: string;
+ chain: WalletChain;
+ onBack: () => void;
+ onDone: () => void;
+}) {
+ const [key, setKey] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [importedAddress, setImportedAddress] = useState(null);
+
+ const trimmed = key.trim();
+
+ // For Ethereum we can derive the address locally before importing, so the user sees exactly
+ // which account the pasted key controls. Solana derivation would need an ed25519 lib — the
+ // imported address is shown from Gateway's response instead.
+ const derived = useMemo(() => {
+ if (chain !== "ethereum" || !EVM_KEY_RE.test(trimmed)) return null;
+ try {
+ const hex = (trimmed.startsWith("0x") ? trimmed : `0x${trimmed}`) as `0x${string}`;
+ return privateKeyToAccount(hex).address;
+ } catch {
+ return null;
+ }
+ }, [chain, trimmed]);
+
+ const keyLooksValid =
+ chain === "ethereum" ? derived !== null : SOLANA_KEY_RE.test(trimmed);
+
+ async function handleImport() {
+ setError(null);
+ setSaving(true);
+ try {
+ const privateKey =
+ chain === "ethereum" && !trimmed.startsWith("0x") ? `0x${trimmed}` : trimmed;
+ const res = await api.addGatewayWallet(server, { chain, private_key: privateKey });
+ setImportedAddress(res.wallet?.address ?? derived ?? "");
+ setKey("");
+ window.setTimeout(onDone, 1500);
+ } catch (e) {
+ const err = e as { message?: string };
+ setError(err.message || "Failed to import wallet into Gateway.");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ Connect {CHAIN_LABEL[chain]} Wallet
+
+
+ Gateway signs DEX transactions on your server, so it needs the wallet's private key.
+ Browser wallets never share private keys with apps — export the key manually and paste it
+ below. It is sent to your Hummingbot API server and stored encrypted by Gateway.
+
+ {chain === "ethereum"
+ ? "Expected a 64-character hex private key (with or without 0x prefix)."
+ : "Expected a base58 private key (no 0x, no brackets)."}
+
+ )}
+
+
+
+
+
+ Prefer a dedicated trading wallet funded with only what you intend to trade, rather
+ than your main wallet.
+
+
- Gateway holds the wallet key on your server so agents and strategies can sign DEX
- transactions. Pick the chain to connect.
-
-
- {WALLET_CHAINS.map(({ chain, label, hint }) => (
+
+
Add Wallet to Gateway
+
+ Gateway signs DEX transactions on your server, so it needs the wallet's private key.
+ Browser wallets never share private keys with apps — export the key manually and paste
+ it below. It is sent to your Hummingbot API server and stored encrypted by Gateway.
+
);
}
@@ -465,8 +476,10 @@ export function ApiKeysSettings() {
diff --git a/frontend/src/components/settings/ImportGatewayWallet.tsx b/frontend/src/components/settings/ImportGatewayWallet.tsx
index eff422b21..e9bb786a1 100644
--- a/frontend/src/components/settings/ImportGatewayWallet.tsx
+++ b/frontend/src/components/settings/ImportGatewayWallet.tsx
@@ -1,4 +1,4 @@
-import { AlertCircle, ArrowLeft, Check, Loader2, ShieldAlert } from "lucide-react";
+import { AlertCircle, Check, Loader2, ShieldAlert } from "lucide-react";
import { useMemo, useState } from "react";
import { privateKeyToAccount } from "viem/accounts";
@@ -6,11 +6,6 @@ import { api } from "@/lib/api";
export type WalletChain = "solana" | "ethereum";
-const CHAIN_LABEL: Record = {
- solana: "Solana",
- ethereum: "Ethereum",
-};
-
// Browser wallets (Phantom, MetaMask, Rabby) never expose private keys to websites — that's by
// design, and why this flow is a manual export + paste instead of a one-click "Connect".
const EXPORT_INSTRUCTIONS: Record = {
@@ -84,25 +79,6 @@ export function ImportGatewayWallet({
return (
-
-
-
-
- Connect {CHAIN_LABEL[chain]} Wallet
-
-
- Gateway signs DEX transactions on your server, so it needs the wallet's private key.
- Browser wallets never share private keys with apps — export the key manually and paste it
- below. It is sent to your Hummingbot API server and stored encrypted by Gateway.
-
-
-
Where to find your private key
From 53d1cfc608bee5ee0f3af2a438c9448d9189b13f Mon Sep 17 00:00:00 2001
From: Michael Feng
Date: Mon, 10 Aug 2026 16:44:26 -0700
Subject: [PATCH 4/7] feat(web): set-default checkbox on wallet import
Every added Gateway wallet silently became the chain default (the API's
set_default defaults to true), so a 2nd wallet would steal the default.
Add a "Set as default wallet for this chain" checkbox (checked by
default). The SDK client doesn't expose set_default, so when unchecked
the backend captures the previous default before adding and restores it
after. Also drop the dedicated-trading-wallet note.
Co-Authored-By: Claude Fable 5
---
condor/web/models.py | 1 +
condor/web/routes/settings.py | 16 ++++++++++++
.../settings/ImportGatewayWallet.tsx | 26 ++++++++++++-------
frontend/src/lib/api.ts | 2 +-
4 files changed, 35 insertions(+), 10 deletions(-)
diff --git a/condor/web/models.py b/condor/web/models.py
index 84d819141..89f967a9d 100644
--- a/condor/web/models.py
+++ b/condor/web/models.py
@@ -512,6 +512,7 @@ class GatewayPullRequest(BaseModel):
class AddGatewayWalletRequest(BaseModel):
chain: str
private_key: str
+ set_default: bool = True
class AddCredentialRequest(BaseModel):
diff --git a/condor/web/routes/settings.py b/condor/web/routes/settings.py
index 402a0b6dc..e0387a1b3 100644
--- a/condor/web/routes/settings.py
+++ b/condor/web/routes/settings.py
@@ -294,10 +294,26 @@ async def add_gateway_wallet(
raise HTTPException(status_code=403, detail="No access")
client = await _get_client(cm, server)
try:
+ # The SDK's add_gateway_wallet always sets the new wallet as the chain default
+ # (the API's set_default flag isn't exposed in the client), so to honor
+ # set_default=False we capture the current default and restore it after adding.
+ previous_default = None
+ if not req.set_default:
+ wallets = await client.accounts.list_gateway_wallets()
+ for group in wallets:
+ if group.get("chain") == req.chain:
+ previous_default = group.get("default_address") or None
+ break
+
result = await client.accounts.add_gateway_wallet(
chain=req.chain,
private_key=req.private_key,
)
+
+ if previous_default and previous_default != result.get("address"):
+ await client.accounts.set_default_gateway_wallet(
+ chain=req.chain, address=previous_default
+ )
return {"added": True, "wallet": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
diff --git a/frontend/src/components/settings/ImportGatewayWallet.tsx b/frontend/src/components/settings/ImportGatewayWallet.tsx
index e9bb786a1..e33743477 100644
--- a/frontend/src/components/settings/ImportGatewayWallet.tsx
+++ b/frontend/src/components/settings/ImportGatewayWallet.tsx
@@ -1,4 +1,4 @@
-import { AlertCircle, Check, Loader2, ShieldAlert } from "lucide-react";
+import { AlertCircle, Check, Loader2 } from "lucide-react";
import { useMemo, useState } from "react";
import { privateKeyToAccount } from "viem/accounts";
@@ -37,6 +37,7 @@ export function ImportGatewayWallet({
onDone: () => void;
}) {
const [key, setKey] = useState("");
+ const [setDefault, setSetDefault] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [importedAddress, setImportedAddress] = useState(null);
@@ -65,7 +66,11 @@ export function ImportGatewayWallet({
try {
const privateKey =
chain === "ethereum" && !trimmed.startsWith("0x") ? `0x${trimmed}` : trimmed;
- const res = await api.addGatewayWallet(server, { chain, private_key: privateKey });
+ const res = await api.addGatewayWallet(server, {
+ chain,
+ private_key: privateKey,
+ set_default: setDefault,
+ });
setImportedAddress(res.wallet?.address ?? derived ?? "");
setKey("");
window.setTimeout(onDone, 1500);
@@ -135,13 +140,16 @@ export function ImportGatewayWallet({
)}
-
-
-
- Prefer a dedicated trading wallet funded with only what you intend to trade, rather
- than your main wallet.
-
-
+
{error && (
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 092aeed9e..e446e1c9c 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1702,7 +1702,7 @@ export const api = {
`/api/v1/settings/gateway/wallets?server=${encodeURIComponent(server)}`,
),
- addGatewayWallet: (server: string, data: { chain: string; private_key: string }) =>
+ addGatewayWallet: (server: string, data: { chain: string; private_key: string; set_default?: boolean }) =>
apiFetch<{ added: boolean; wallet: { address?: string } }>(
`/api/v1/settings/gateway/wallets?server=${encodeURIComponent(server)}`,
{ method: "POST", body: JSON.stringify(data) },
From 4781221da32647679fc1c2f719b30e1297d801ea Mon Sep 17 00:00:00 2001
From: Michael Feng
Date: Mon, 10 Aug 2026 17:55:13 -0700
Subject: [PATCH 5/7] feat(meteora_launch_lp): fix fee-yield units 100x, add
charts/reports, add Fee Decay Harvester strategy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Units fix: the Meteora DAMM v2 API's fee_tvl_ratio is ALREADY a percent
per window (verified fees.24h/tvl == fee_tvl_ratio.24h), so the x100
display in both scanners overstated yields 100x. Display raw percent and
add a units warning to both strategies (annualize by x365 only).
- Visuals: both scanners now emit a plotly chart (PNG to Telegram via
send_photo, interactive in a saved ReportBuilder report with KPI cards
and table). easya_graduation_monitor renders a candlestick+volume panel
of the pool's full post-graduation life via GeckoTerminal OHLCV
(Meteora's native OHLCV caps at ~10 bars; kept as fallback with an
adaptive timeframe).
- New strategy fee_decay_harvester: customization demo on the same agent +
routines — enters established verified pools on a dual-window (24h+4h)
fee-APR persistence screen instead of fresh graduations, and manages
liquidity with tranche-based exits (50% scale-out on APR decay past
half, 100% on APR floor, symmetric IL/price deviation, volume collapse,
7-day max hold). Dry-run tick verified: rejected one-candle yield
spikes, gated and sized a would-be entry with zero writes.
Co-Authored-By: Claude Fable 5
---
.../routines/damm_v2_scanner.py | 100 ++++++++++++-
.../routines/easya_graduation_monitor.py | 131 ++++++++++++++++-
.../fee_decay_harvester/strategy.md | 136 ++++++++++++++++++
.../strategies/launch_lp_operator/strategy.md | 4 +-
4 files changed, 366 insertions(+), 5 deletions(-)
create mode 100644 agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md
diff --git a/agents/meteora_launch_lp/routines/damm_v2_scanner.py b/agents/meteora_launch_lp/routines/damm_v2_scanner.py
index 52ba7d534..e59df21bf 100644
--- a/agents/meteora_launch_lp/routines/damm_v2_scanner.py
+++ b/agents/meteora_launch_lp/routines/damm_v2_scanner.py
@@ -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
@@ -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']} "
+ f"{detail}")
+ 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="%{y} Fee APR: %{text}",
+ 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):
@@ -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}",
@@ -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:
diff --git a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py
index 1fb196159..33835b8ee 100644
--- a/agents/meteora_launch_lp/routines/easya_graduation_monitor.py
+++ b/agents/meteora_launch_lp/routines/easya_graduation_monitor.py
@@ -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
@@ -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."""
@@ -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",
@@ -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:
diff --git a/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md b/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md
new file mode 100644
index 000000000..038c54e57
--- /dev/null
+++ b/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md
@@ -0,0 +1,136 @@
+---
+name: Fee Decay Harvester
+description: 'Customized variant of the launch LP loop: enters established, verified
+ DAMM v2 pools on persistent (dual-window) fee yield instead of fresh graduations,
+ and manages liquidity with tranche-based exits keyed to fee-APR decay half-life,
+ IL/price deviation, volume collapse, and max hold time.'
+agent_key: null
+skills:
+- launch_safety_check
+default_config:
+ frequency_sec: 3600
+ execution_mode: loop
+ quote_asset: SOL
+ total_amount_quote: 0.8
+ max_positions: 2
+ base_pct: 50
+ entry:
+ min_tvl_usd: 50000
+ verified_only: true
+ min_fee_apr_pct_24h: 40
+ min_fee_apr_pct_4h: 25
+ min_vol_persistence: 0.5
+ max_top10_holder_pct: 50
+ min_lock_pct: 50
+ require_mint_renounced: true
+ require_freeze_disabled: true
+ exit:
+ apr_decay_scaleout_ratio: 0.5
+ apr_floor_pct: 15
+ il_price_deviation_pct: 20
+ vol_collapse_ratio: 0.3
+ max_hold_hours: 168
+ risk_limits:
+ min_wallet_sol_reserve: 0.3
+ max_positions: 2
+ max_quote_per_position: 0.4
+ rpc_url: ''
+default_trading_context: ''
+created_by: 456181693
+created_at: '2026-08-11T00:31:34.670699+00:00'
+---
+
+# Fee Decay Harvester
+
+You are the Meteora Launch LP agent's **established-pool fee-yield strategy** — a customized variant
+of `launch_lp_operator`. You do NOT chase fresh graduations. You enter **established, verified DAMM
+v2 pools whose fee yield is persistent**, and you manage each position with **tranche-based exits**
+keyed to fee-APR decay, IL/price deviation, volume collapse, and max hold. Positions are DAMM v2 NFT
+positions via `manage_amm` — never executors, never controllers.
+
+## HARD TICK BUDGET
+~1-hour tick. Aim for ≤ 12 tool calls. Detect with the **`damm_v2_scanner` routine** (two calls, two
+windows) — do NOT hand-scan.
+
+## Constants
+`connector` = `meteora` · `network` = `solana-mainnet-beta` · quote = SOL. Full-range AMM: no ranges,
+no bins, no rebalancing.
+
+## Fee-APR units (get this right)
+The Meteora API's `fee_tvl_ratio` is **already a percentage per window** (e.g. `24h: 5.22` means
+fees over the last 24h were 5.22% of TVL). Annualize by windows-per-year ONLY:
+`fee_apr_pct = fee_tvl_ratio.24h × 365` (or `× 2190` for the 4h window). Do NOT multiply by 100.
+The `damm_v2_scanner` FeeYield column is the raw window percentage.
+
+## Each Tick
+
+### 1. Load state — adopt every live position
+`manage_amm(action="positions_owned", …)`; every non-zero-liquidity position is open. Cross-reference
+the journal for each position's `entry_fee_apr`, `entry_price`, `entry_vol24h`, `sol_in`, entry time,
+and `scaled_out` flag. Adopt any live position missing from the journal (reconstruct best-effort from
+`position_info`, mark `entry_fee_apr` unknown → decay exits fall back to the absolute `apr_floor_pct`).
+
+### 2. Manage open positions — TRANCHE-BASED exit brain (this is the variant's core)
+For each open position, read `position_info` + `pool_info`, and the pool's current row from
+`damm_v2_scanner` output (fee yield windows, volume). Compute:
+- `fee_apr_now` = pool `fee_tvl_ratio.24h` × 365 (as %; see units note above)
+- `price_dev_pct` = |price_now − entry_price| / entry_price × 100 (IL proxy — full-range IL is
+ symmetric, so deviation in EITHER direction counts)
+- `vol_persistence_now` = (vol_4h × 6) / entry_vol24h
+
+Apply in order:
+1. **Fee-APR decay scale-out** — if `fee_apr_now < entry_fee_apr × apr_decay_scaleout_ratio` and not
+ yet `scaled_out`: `remove_liquidity(position_address, percentage_to_remove=50)`, journal
+ `scaled_out=true`. The yield that justified the position has halved — de-risk half, keep half
+ earning.
+2. **Fee-APR floor** — if `fee_apr_now < apr_floor_pct`: exit 100%.
+3. **IL / price deviation** — if `price_dev_pct ≥ il_price_deviation_pct`: exit 100% (fees can no
+ longer be assumed to outrun IL).
+4. **Volume collapse** — if `vol_persistence_now < vol_collapse_ratio`: exit 100% (flow is gone;
+ fee APR lags volume, act on volume first).
+5. **Max hold** — position age ≥ `max_hold_hours`: exit 100%, re-qualify from scratch if still
+ attractive.
+
+After any 100% exit, swap the returned base token to SOL (`execute_swap`, side=SELL). Journal every
+exit with which trigger fired and realized pnl_pct.
+
+### 3. Detect — dual-window persistence screen (only if free slots)
+Run `damm_v2_scanner` TWICE via `manage_routines(action="run", name="damm_v2_scanner",
+strategy_id="meteora_launch_lp.fee_decay_harvester", config={…})`:
+- Pass A: `{"ranking_window": "24h", "min_tvl_usd": , "verified_only": true,
+ "top_n": 10, "exclude_pools": []}`
+- Pass B: same but `"ranking_window": "4h"`.
+
+A candidate qualifies only if it appears in BOTH passes with:
+- annualized fee APR ≥ `min_fee_apr_pct_24h` on the 24h window (FeeYield% × 365) AND
+ ≥ `min_fee_apr_pct_4h` on the 4h window (FeeYield% × 2190) — yield is persistent, not one candle;
+- `vol_4h × 6 ≥ min_vol_persistence × vol_24h` (intra-day flow is holding up).
+Rank qualifiers by 24h fee APR; take the top one not already held.
+
+### 4. Gate (stricter than the launch variant)
+1. Sellability round-trip: `quote_swap` SELL and BUY both sane (impact ≈ pool fee).
+2. `launch_safety_check` routine with `{"pool_address":
, "rpc_url": ,
+ "max_top10_holder_pct": , "min_lock_pct": ,
+ "require_verified": true}` → must be PASS.
+
+### 5. Open ONE position
+Size = min(`total_amount_quote / max_positions`, `max_quote_per_position`), respecting
+`min_wallet_sol_reserve`. Acquire base side via `execute_swap` (side=BUY, haircut received ×0.995),
+`quote_liquidity`, then `add_liquidity` WITHOUT `position_address` (new NFT). Journal
+`position_address`, `sol_in`, `entry_price`, `entry_fee_apr` (annualized 24h yield at entry),
+`entry_vol24h`, `scaled_out=false`. `send_notification` with pool + signature.
+
+### 6. Journal
+One `action` entry per tick: open positions with fee_apr_now vs entry, trigger states, any
+scale-out/exit, free slots. `learning` only when a trigger's behavior teaches something new.
+
+## Risk rules
+- Never exceed `max_quote_per_position`; keep `min_wallet_sol_reserve` SOL free.
+- One position per pool; one open per tick.
+- The scale-out (trigger 1) fires at most once per position — never average back in after it.
+- On a failed add after a landed swap: retry the add once with the true wallet balance, else swap
+ back to SOL. Never leave acquired base tokens unmanaged.
+
+## Error recovery
+On any tool failure: journal it, hold, retry next tick. If `damm_v2_scanner` errors, skip detection
+this tick (manage existing positions only).
diff --git a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md
index b3feb78e7..31975dbe5 100644
--- a/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md
+++ b/agents/meteora_launch_lp/strategies/launch_lp_operator/strategy.md
@@ -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
From 574b1a251c53007e37831d3f445f17f4fafbe89e Mon Sep 17 00:00:00 2001
From: Michael Feng
Date: Mon, 10 Aug 2026 18:37:48 -0700
Subject: [PATCH 6/7] chore(meteora_launch_lp): drop fee_decay_harvester
strategy file from repo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Kept out of the repo intentionally — it is the live-demo artifact for
"customize your own strategy on the same agent"; shipping it pre-made
would defeat the demo. The dry-run-validated playbook lives in the PR
description and the operator's local copy.
Co-Authored-By: Claude Fable 5
---
.../fee_decay_harvester/strategy.md | 136 ------------------
1 file changed, 136 deletions(-)
delete mode 100644 agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md
diff --git a/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md b/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md
deleted file mode 100644
index 038c54e57..000000000
--- a/agents/meteora_launch_lp/strategies/fee_decay_harvester/strategy.md
+++ /dev/null
@@ -1,136 +0,0 @@
----
-name: Fee Decay Harvester
-description: 'Customized variant of the launch LP loop: enters established, verified
- DAMM v2 pools on persistent (dual-window) fee yield instead of fresh graduations,
- and manages liquidity with tranche-based exits keyed to fee-APR decay half-life,
- IL/price deviation, volume collapse, and max hold time.'
-agent_key: null
-skills:
-- launch_safety_check
-default_config:
- frequency_sec: 3600
- execution_mode: loop
- quote_asset: SOL
- total_amount_quote: 0.8
- max_positions: 2
- base_pct: 50
- entry:
- min_tvl_usd: 50000
- verified_only: true
- min_fee_apr_pct_24h: 40
- min_fee_apr_pct_4h: 25
- min_vol_persistence: 0.5
- max_top10_holder_pct: 50
- min_lock_pct: 50
- require_mint_renounced: true
- require_freeze_disabled: true
- exit:
- apr_decay_scaleout_ratio: 0.5
- apr_floor_pct: 15
- il_price_deviation_pct: 20
- vol_collapse_ratio: 0.3
- max_hold_hours: 168
- risk_limits:
- min_wallet_sol_reserve: 0.3
- max_positions: 2
- max_quote_per_position: 0.4
- rpc_url: ''
-default_trading_context: ''
-created_by: 456181693
-created_at: '2026-08-11T00:31:34.670699+00:00'
----
-
-# Fee Decay Harvester
-
-You are the Meteora Launch LP agent's **established-pool fee-yield strategy** — a customized variant
-of `launch_lp_operator`. You do NOT chase fresh graduations. You enter **established, verified DAMM
-v2 pools whose fee yield is persistent**, and you manage each position with **tranche-based exits**
-keyed to fee-APR decay, IL/price deviation, volume collapse, and max hold. Positions are DAMM v2 NFT
-positions via `manage_amm` — never executors, never controllers.
-
-## HARD TICK BUDGET
-~1-hour tick. Aim for ≤ 12 tool calls. Detect with the **`damm_v2_scanner` routine** (two calls, two
-windows) — do NOT hand-scan.
-
-## Constants
-`connector` = `meteora` · `network` = `solana-mainnet-beta` · quote = SOL. Full-range AMM: no ranges,
-no bins, no rebalancing.
-
-## Fee-APR units (get this right)
-The Meteora API's `fee_tvl_ratio` is **already a percentage per window** (e.g. `24h: 5.22` means
-fees over the last 24h were 5.22% of TVL). Annualize by windows-per-year ONLY:
-`fee_apr_pct = fee_tvl_ratio.24h × 365` (or `× 2190` for the 4h window). Do NOT multiply by 100.
-The `damm_v2_scanner` FeeYield column is the raw window percentage.
-
-## Each Tick
-
-### 1. Load state — adopt every live position
-`manage_amm(action="positions_owned", …)`; every non-zero-liquidity position is open. Cross-reference
-the journal for each position's `entry_fee_apr`, `entry_price`, `entry_vol24h`, `sol_in`, entry time,
-and `scaled_out` flag. Adopt any live position missing from the journal (reconstruct best-effort from
-`position_info`, mark `entry_fee_apr` unknown → decay exits fall back to the absolute `apr_floor_pct`).
-
-### 2. Manage open positions — TRANCHE-BASED exit brain (this is the variant's core)
-For each open position, read `position_info` + `pool_info`, and the pool's current row from
-`damm_v2_scanner` output (fee yield windows, volume). Compute:
-- `fee_apr_now` = pool `fee_tvl_ratio.24h` × 365 (as %; see units note above)
-- `price_dev_pct` = |price_now − entry_price| / entry_price × 100 (IL proxy — full-range IL is
- symmetric, so deviation in EITHER direction counts)
-- `vol_persistence_now` = (vol_4h × 6) / entry_vol24h
-
-Apply in order:
-1. **Fee-APR decay scale-out** — if `fee_apr_now < entry_fee_apr × apr_decay_scaleout_ratio` and not
- yet `scaled_out`: `remove_liquidity(position_address, percentage_to_remove=50)`, journal
- `scaled_out=true`. The yield that justified the position has halved — de-risk half, keep half
- earning.
-2. **Fee-APR floor** — if `fee_apr_now < apr_floor_pct`: exit 100%.
-3. **IL / price deviation** — if `price_dev_pct ≥ il_price_deviation_pct`: exit 100% (fees can no
- longer be assumed to outrun IL).
-4. **Volume collapse** — if `vol_persistence_now < vol_collapse_ratio`: exit 100% (flow is gone;
- fee APR lags volume, act on volume first).
-5. **Max hold** — position age ≥ `max_hold_hours`: exit 100%, re-qualify from scratch if still
- attractive.
-
-After any 100% exit, swap the returned base token to SOL (`execute_swap`, side=SELL). Journal every
-exit with which trigger fired and realized pnl_pct.
-
-### 3. Detect — dual-window persistence screen (only if free slots)
-Run `damm_v2_scanner` TWICE via `manage_routines(action="run", name="damm_v2_scanner",
-strategy_id="meteora_launch_lp.fee_decay_harvester", config={…})`:
-- Pass A: `{"ranking_window": "24h", "min_tvl_usd": , "verified_only": true,
- "top_n": 10, "exclude_pools": []}`
-- Pass B: same but `"ranking_window": "4h"`.
-
-A candidate qualifies only if it appears in BOTH passes with:
-- annualized fee APR ≥ `min_fee_apr_pct_24h` on the 24h window (FeeYield% × 365) AND
- ≥ `min_fee_apr_pct_4h` on the 4h window (FeeYield% × 2190) — yield is persistent, not one candle;
-- `vol_4h × 6 ≥ min_vol_persistence × vol_24h` (intra-day flow is holding up).
-Rank qualifiers by 24h fee APR; take the top one not already held.
-
-### 4. Gate (stricter than the launch variant)
-1. Sellability round-trip: `quote_swap` SELL and BUY both sane (impact ≈ pool fee).
-2. `launch_safety_check` routine with `{"pool_address":
, "rpc_url": ,
- "max_top10_holder_pct": , "min_lock_pct": ,
- "require_verified": true}` → must be PASS.
-
-### 5. Open ONE position
-Size = min(`total_amount_quote / max_positions`, `max_quote_per_position`), respecting
-`min_wallet_sol_reserve`. Acquire base side via `execute_swap` (side=BUY, haircut received ×0.995),
-`quote_liquidity`, then `add_liquidity` WITHOUT `position_address` (new NFT). Journal
-`position_address`, `sol_in`, `entry_price`, `entry_fee_apr` (annualized 24h yield at entry),
-`entry_vol24h`, `scaled_out=false`. `send_notification` with pool + signature.
-
-### 6. Journal
-One `action` entry per tick: open positions with fee_apr_now vs entry, trigger states, any
-scale-out/exit, free slots. `learning` only when a trigger's behavior teaches something new.
-
-## Risk rules
-- Never exceed `max_quote_per_position`; keep `min_wallet_sol_reserve` SOL free.
-- One position per pool; one open per tick.
-- The scale-out (trigger 1) fires at most once per position — never average back in after it.
-- On a failed add after a landed swap: retry the add once with the true wallet balance, else swap
- back to SOL. Never leave acquired base tokens unmanaged.
-
-## Error recovery
-On any tool failure: journal it, hold, retry next tick. If `damm_v2_scanner` errors, skip detection
-this tick (manage existing positions only).
From 70e0f455846cc72872dfebfee4a5cc32468718af Mon Sep 17 00:00:00 2001
From: Michael Feng
Date: Mon, 10 Aug 2026 21:05:02 -0700
Subject: [PATCH 7/7] fix(web): count Gateway wallets toward the connect-keys
gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
With only a Solana/Ethereum Gateway wallet added (no CEX credentials),
the portfolio page still showed the Connect Keys overlay — useCredentials
gated on exchange credentials alone. A Gateway wallet now unlocks the app
the same way a CEX key does (Gateway-unreachable errors count as no
wallets). Overlay copy updated to mention wallets.
Co-Authored-By: Claude Fable 5
---
frontend/src/components/ConnectKeysOverlay.tsx | 7 ++++---
frontend/src/hooks/useCredentials.ts | 18 ++++++++++++++++--
2 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/frontend/src/components/ConnectKeysOverlay.tsx b/frontend/src/components/ConnectKeysOverlay.tsx
index cace8df33..571d23914 100644
--- a/frontend/src/components/ConnectKeysOverlay.tsx
+++ b/frontend/src/components/ConnectKeysOverlay.tsx
@@ -19,10 +19,11 @@ export function ConnectKeysOverlay() {
{/* Copy */}
- Connect Your Exchange
+ Connect an Exchange or Wallet
- Add your exchange API keys to unlock portfolio tracking, trading, and bot management.
+ Add exchange API keys or a DEX wallet to unlock portfolio tracking, trading, and bot
+ management.