594 lines
19 KiB
Python
594 lines
19 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""Generate ranked trade recommendations for Polymarket prediction markets.
|
||
|
|
|
||
|
|
Scans active markets, scores edges (arbitrage, momentum, orderbook imbalance),
|
||
|
|
applies Kelly criterion sizing, validates against risk rules, and outputs
|
||
|
|
actionable trade recommendations as JSON.
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python advisor.py --top 5
|
||
|
|
python advisor.py --portfolio-db ~/.polymarket-paper/portfolio.db --top 5
|
||
|
|
python advisor.py --min-volume 50000 --min-edge 0.03 --top 10
|
||
|
|
"""
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import json
|
||
|
|
import math
|
||
|
|
import os
|
||
|
|
import sqlite3
|
||
|
|
import sys
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
|
||
|
|
import requests
|
||
|
|
|
||
|
|
GAMMA_API = "https://gamma-api.polymarket.com"
|
||
|
|
CLOB_API = "https://clob.polymarket.com"
|
||
|
|
|
||
|
|
DEFAULT_PORTFOLIO_VALUE = 10000.0
|
||
|
|
DEFAULT_MAX_POSITION_PCT = 0.10
|
||
|
|
DEFAULT_MAX_OPEN_POSITIONS = 5
|
||
|
|
DEFAULT_MIN_EDGE = 0.03
|
||
|
|
DEFAULT_MIN_VOLUME = 10000.0
|
||
|
|
DEFAULT_MIN_CONFIDENCE = 0.5
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_markets(limit=100, min_volume=0):
|
||
|
|
"""Fetch active markets from Gamma API sorted by 24h volume."""
|
||
|
|
params = {
|
||
|
|
"limit": min(limit, 100),
|
||
|
|
"active": "true",
|
||
|
|
"closed": "false",
|
||
|
|
"order": "volume24hr",
|
||
|
|
"ascending": "false",
|
||
|
|
}
|
||
|
|
resp = requests.get(f"{GAMMA_API}/markets", params=params, timeout=30)
|
||
|
|
resp.raise_for_status()
|
||
|
|
raw = resp.json()
|
||
|
|
|
||
|
|
markets = []
|
||
|
|
for m in raw:
|
||
|
|
vol_24h = float(m.get("volume24hr", 0) or 0)
|
||
|
|
if vol_24h < min_volume:
|
||
|
|
continue
|
||
|
|
if not m.get("acceptingOrders", False):
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
outcomes = json.loads(m.get("outcomes", "[]"))
|
||
|
|
except (json.JSONDecodeError, TypeError):
|
||
|
|
outcomes = []
|
||
|
|
try:
|
||
|
|
prices = json.loads(m.get("outcomePrices", "[]"))
|
||
|
|
prices = [float(p) for p in prices]
|
||
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||
|
|
prices = []
|
||
|
|
try:
|
||
|
|
token_ids = json.loads(m.get("clobTokenIds", "[]"))
|
||
|
|
except (json.JSONDecodeError, TypeError):
|
||
|
|
token_ids = []
|
||
|
|
|
||
|
|
# Only handle binary markets (2 outcomes) for now
|
||
|
|
if len(outcomes) != 2 or len(prices) != 2 or len(token_ids) != 2:
|
||
|
|
continue
|
||
|
|
|
||
|
|
end_date = m.get("endDate", "")
|
||
|
|
if end_date:
|
||
|
|
try:
|
||
|
|
end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
|
||
|
|
hours_left = (end_dt - datetime.now(timezone.utc)).total_seconds() / 3600
|
||
|
|
if hours_left < 24:
|
||
|
|
continue
|
||
|
|
except (ValueError, TypeError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
markets.append({
|
||
|
|
"question": m.get("question", ""),
|
||
|
|
"slug": m.get("slug", ""),
|
||
|
|
"condition_id": m.get("conditionID", ""),
|
||
|
|
"outcomes": outcomes,
|
||
|
|
"prices": prices,
|
||
|
|
"token_ids": token_ids,
|
||
|
|
"volume_24h": vol_24h,
|
||
|
|
"liquidity": float(m.get("liquidityNum", 0) or 0),
|
||
|
|
"end_date": end_date,
|
||
|
|
})
|
||
|
|
|
||
|
|
return markets
|
||
|
|
|
||
|
|
|
||
|
|
def fetch_orderbook(token_id):
|
||
|
|
"""Fetch orderbook for a token from CLOB API."""
|
||
|
|
try:
|
||
|
|
resp = requests.get(
|
||
|
|
f"{CLOB_API}/book",
|
||
|
|
params={"token_id": token_id},
|
||
|
|
timeout=15,
|
||
|
|
)
|
||
|
|
resp.raise_for_status()
|
||
|
|
return resp.json()
|
||
|
|
except requests.RequestException:
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def calculate_spread(orderbook):
|
||
|
|
"""Calculate spread and imbalance from orderbook data."""
|
||
|
|
if not orderbook:
|
||
|
|
return None
|
||
|
|
|
||
|
|
bids = orderbook.get("bids", [])
|
||
|
|
asks = orderbook.get("asks", [])
|
||
|
|
|
||
|
|
if not bids or not asks:
|
||
|
|
return None
|
||
|
|
|
||
|
|
best_bid = float(bids[0].get("price", 0))
|
||
|
|
best_ask = float(asks[0].get("price", 1))
|
||
|
|
spread = best_ask - best_bid
|
||
|
|
midpoint = (best_bid + best_ask) / 2
|
||
|
|
|
||
|
|
bid_depth = sum(float(b.get("size", 0)) for b in bids[:5])
|
||
|
|
ask_depth = sum(float(a.get("size", 0)) for a in asks[:5])
|
||
|
|
total_depth = bid_depth + ask_depth
|
||
|
|
imbalance = (bid_depth - ask_depth) / total_depth if total_depth > 0 else 0
|
||
|
|
|
||
|
|
return {
|
||
|
|
"best_bid": best_bid,
|
||
|
|
"best_ask": best_ask,
|
||
|
|
"spread": spread,
|
||
|
|
"spread_pct": spread / midpoint if midpoint > 0 else 0,
|
||
|
|
"midpoint": midpoint,
|
||
|
|
"bid_depth": bid_depth,
|
||
|
|
"ask_depth": ask_depth,
|
||
|
|
"imbalance": imbalance,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def detect_arbitrage(yes_price, no_price):
|
||
|
|
"""Detect YES+NO arbitrage. Returns edge if underpriced."""
|
||
|
|
total = yes_price + no_price
|
||
|
|
if total < 0.99: # Underpriced: buying both sides guarantees profit
|
||
|
|
return {
|
||
|
|
"type": "arbitrage",
|
||
|
|
"edge": 1.0 - total,
|
||
|
|
"direction": "both",
|
||
|
|
"detail": f"YES+NO={total:.4f}, guaranteed ${1.0 - total:.4f}/share profit",
|
||
|
|
}
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def detect_momentum(imbalance, volume_24h, liquidity):
|
||
|
|
"""Detect momentum signal from orderbook imbalance and volume."""
|
||
|
|
if liquidity <= 0:
|
||
|
|
return None
|
||
|
|
|
||
|
|
volume_liquidity_ratio = volume_24h / liquidity
|
||
|
|
# High volume relative to liquidity + orderbook imbalance = momentum
|
||
|
|
if abs(imbalance) > 0.3 and volume_liquidity_ratio > 2.0:
|
||
|
|
direction = "YES" if imbalance > 0 else "NO"
|
||
|
|
strength = min(abs(imbalance) * volume_liquidity_ratio / 10, 1.0)
|
||
|
|
edge = abs(imbalance) * 0.15 # Conservative edge estimate
|
||
|
|
return {
|
||
|
|
"type": "momentum",
|
||
|
|
"edge": edge,
|
||
|
|
"direction": direction,
|
||
|
|
"detail": (
|
||
|
|
f"Orderbook imbalance={imbalance:+.2f}, "
|
||
|
|
f"volume/liquidity={volume_liquidity_ratio:.1f}x, "
|
||
|
|
f"momentum favors {direction}"
|
||
|
|
),
|
||
|
|
"strength": strength,
|
||
|
|
}
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def detect_spread_opportunity(spread_pct, midpoint):
|
||
|
|
"""Detect wide-spread mean reversion opportunity."""
|
||
|
|
# If spread is wide (5-10%), there may be a mean reversion opportunity
|
||
|
|
# by placing a limit order at the midpoint
|
||
|
|
if 0.05 <= spread_pct <= 0.10 and 0.15 < midpoint < 0.85:
|
||
|
|
edge = spread_pct * 0.3 # Conservatively capture 30% of spread
|
||
|
|
return {
|
||
|
|
"type": "mean-reversion",
|
||
|
|
"edge": edge,
|
||
|
|
"direction": "YES" if midpoint < 0.5 else "NO",
|
||
|
|
"detail": (
|
||
|
|
f"Wide spread={spread_pct:.1%}, midpoint={midpoint:.3f}. "
|
||
|
|
f"Limit order near midpoint captures spread."
|
||
|
|
),
|
||
|
|
}
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def kelly_half(estimated_prob, market_price, side="YES"):
|
||
|
|
"""Calculate half-Kelly position fraction for a binary market.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
estimated_prob: Your estimated probability that YES resolves to 1.
|
||
|
|
market_price: Current price of the side you are buying.
|
||
|
|
side: "YES" or "NO".
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Half-Kelly fraction (0 to 1), or 0 if negative EV.
|
||
|
|
"""
|
||
|
|
if side == "YES":
|
||
|
|
p = estimated_prob
|
||
|
|
cost = market_price
|
||
|
|
else:
|
||
|
|
p = 1.0 - estimated_prob
|
||
|
|
cost = market_price
|
||
|
|
|
||
|
|
if cost <= 0 or cost >= 1:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
# Payout is 1.0 per share, cost is market_price
|
||
|
|
# b = net payout / cost = (1 - cost) / cost
|
||
|
|
b = (1.0 - cost) / cost
|
||
|
|
q = 1.0 - p
|
||
|
|
if b <= 0:
|
||
|
|
return 0
|
||
|
|
|
||
|
|
kelly = (b * p - q) / b
|
||
|
|
return max(0, kelly * 0.5)
|
||
|
|
|
||
|
|
|
||
|
|
def load_portfolio(db_path):
|
||
|
|
"""Load portfolio state from paper trader SQLite database.
|
||
|
|
|
||
|
|
Returns dict with keys: value, cash, positions, peak_value, daily_pnl,
|
||
|
|
open_position_count. Returns defaults if DB does not exist.
|
||
|
|
"""
|
||
|
|
if not db_path or not os.path.exists(db_path):
|
||
|
|
return {
|
||
|
|
"value": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"cash": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"positions": [],
|
||
|
|
"peak_value": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"daily_pnl": 0.0,
|
||
|
|
"open_position_count": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
conn = sqlite3.connect(db_path)
|
||
|
|
conn.row_factory = sqlite3.Row
|
||
|
|
cur = conn.cursor()
|
||
|
|
|
||
|
|
# Try to read portfolio summary
|
||
|
|
portfolio = {
|
||
|
|
"value": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"cash": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"positions": [],
|
||
|
|
"peak_value": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"daily_pnl": 0.0,
|
||
|
|
"open_position_count": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Read account balance from portfolios table
|
||
|
|
try:
|
||
|
|
cur.execute(
|
||
|
|
"SELECT cash_balance, peak_value FROM portfolios "
|
||
|
|
"WHERE active = 1 ORDER BY id DESC LIMIT 1"
|
||
|
|
)
|
||
|
|
row = cur.fetchone()
|
||
|
|
if row:
|
||
|
|
portfolio["cash"] = float(row["cash_balance"])
|
||
|
|
portfolio["peak_value"] = float(row["peak_value"])
|
||
|
|
# Calculate total value: cash + positions value
|
||
|
|
pos_cur = conn.cursor()
|
||
|
|
pos_cur.execute(
|
||
|
|
"SELECT COALESCE(SUM(shares * current_price), 0) as pos_val "
|
||
|
|
"FROM positions WHERE portfolio_id = 1 AND closed = 0"
|
||
|
|
)
|
||
|
|
pos_row = pos_cur.fetchone()
|
||
|
|
pos_val = float(pos_row["pos_val"]) if pos_row else 0.0
|
||
|
|
portfolio["value"] = portfolio["cash"] + pos_val
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Read open positions
|
||
|
|
try:
|
||
|
|
cur.execute(
|
||
|
|
"SELECT token_id, side, shares, avg_entry, market_question "
|
||
|
|
"FROM positions WHERE closed = 0"
|
||
|
|
)
|
||
|
|
positions = [dict(r) for r in cur.fetchall()]
|
||
|
|
portfolio["positions"] = positions
|
||
|
|
portfolio["open_position_count"] = len(positions)
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# Read daily P&L from daily_snapshots
|
||
|
|
try:
|
||
|
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||
|
|
cur.execute(
|
||
|
|
"SELECT daily_pnl FROM daily_snapshots "
|
||
|
|
"WHERE date = ? ORDER BY id DESC LIMIT 1",
|
||
|
|
(today,),
|
||
|
|
)
|
||
|
|
row = cur.fetchone()
|
||
|
|
if row:
|
||
|
|
portfolio["daily_pnl"] = float(row["daily_pnl"])
|
||
|
|
except sqlite3.OperationalError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
conn.close()
|
||
|
|
return portfolio
|
||
|
|
|
||
|
|
except sqlite3.Error:
|
||
|
|
return {
|
||
|
|
"value": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"cash": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"positions": [],
|
||
|
|
"peak_value": DEFAULT_PORTFOLIO_VALUE,
|
||
|
|
"daily_pnl": 0.0,
|
||
|
|
"open_position_count": 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def check_risk_rules(portfolio, position_size_usdc, confidence):
|
||
|
|
"""Validate a proposed trade against risk rules.
|
||
|
|
|
||
|
|
Returns (passed: bool, reason: str).
|
||
|
|
"""
|
||
|
|
pv = portfolio["value"]
|
||
|
|
if pv <= 0:
|
||
|
|
return False, "Portfolio value is zero or negative"
|
||
|
|
|
||
|
|
# Daily loss limit: 5%
|
||
|
|
if portfolio["daily_pnl"] < -pv * 0.05:
|
||
|
|
return False, f"Daily loss limit exceeded: {portfolio['daily_pnl']:.2f}"
|
||
|
|
|
||
|
|
# Drawdown limit: 20%
|
||
|
|
if portfolio["peak_value"] > 0:
|
||
|
|
drawdown = (portfolio["peak_value"] - pv) / portfolio["peak_value"]
|
||
|
|
if drawdown > 0.20:
|
||
|
|
return False, f"Max drawdown exceeded: {drawdown:.1%}"
|
||
|
|
|
||
|
|
# Max open positions: 5
|
||
|
|
if portfolio["open_position_count"] >= DEFAULT_MAX_OPEN_POSITIONS:
|
||
|
|
return False, f"Max open positions reached: {portfolio['open_position_count']}"
|
||
|
|
|
||
|
|
# Position size cap
|
||
|
|
max_pct = DEFAULT_MAX_POSITION_PCT
|
||
|
|
if confidence < 0.7:
|
||
|
|
max_pct = 0.05
|
||
|
|
max_size = pv * max_pct
|
||
|
|
if position_size_usdc > max_size:
|
||
|
|
return False, (
|
||
|
|
f"Position too large: ${position_size_usdc:.2f} > "
|
||
|
|
f"${max_size:.2f} ({max_pct:.0%} of portfolio)"
|
||
|
|
)
|
||
|
|
|
||
|
|
return True, "OK"
|
||
|
|
|
||
|
|
|
||
|
|
def score_market(market, portfolio):
|
||
|
|
"""Analyze a single market and return a trade recommendation or None."""
|
||
|
|
yes_price = market["prices"][0]
|
||
|
|
no_price = market["prices"][1]
|
||
|
|
|
||
|
|
# Skip markets priced at extremes (already resolved in practice)
|
||
|
|
if yes_price < 0.03 or yes_price > 0.97:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Fetch orderbook for the YES token (used for imbalance/depth signals)
|
||
|
|
ob = fetch_orderbook(market["token_ids"][0])
|
||
|
|
spread_info = calculate_spread(ob)
|
||
|
|
|
||
|
|
# Detect edges, pick the strongest
|
||
|
|
edges = []
|
||
|
|
|
||
|
|
arb = detect_arbitrage(yes_price, no_price)
|
||
|
|
if arb:
|
||
|
|
edges.append(arb)
|
||
|
|
|
||
|
|
if spread_info:
|
||
|
|
mom = detect_momentum(
|
||
|
|
spread_info["imbalance"],
|
||
|
|
market["volume_24h"],
|
||
|
|
market["liquidity"],
|
||
|
|
)
|
||
|
|
if mom:
|
||
|
|
edges.append(mom)
|
||
|
|
|
||
|
|
# Mean reversion: only valid when orderbook spread is reasonable
|
||
|
|
# (under 20%), otherwise the midpoint is meaningless
|
||
|
|
if spread_info["spread_pct"] < 0.20:
|
||
|
|
gamma_spread = abs(yes_price - spread_info["midpoint"])
|
||
|
|
if gamma_spread > 0.02 and 0.15 < yes_price < 0.85:
|
||
|
|
edges.append({
|
||
|
|
"type": "mean-reversion",
|
||
|
|
"edge": gamma_spread * 0.5,
|
||
|
|
"direction": "YES" if yes_price < spread_info["midpoint"] else "NO",
|
||
|
|
"detail": (
|
||
|
|
f"Gamma price {yes_price:.3f} deviates from orderbook "
|
||
|
|
f"midpoint {spread_info['midpoint']:.3f} by "
|
||
|
|
f"{gamma_spread:.3f} (book spread {spread_info['spread_pct']:.1%})"
|
||
|
|
),
|
||
|
|
})
|
||
|
|
|
||
|
|
if not edges:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Pick the edge with highest expected value
|
||
|
|
best = max(edges, key=lambda e: e["edge"])
|
||
|
|
|
||
|
|
if best["edge"] < DEFAULT_MIN_EDGE:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Determine trade side and entry price
|
||
|
|
if best["type"] == "arbitrage":
|
||
|
|
side = "YES" # Will also need NO side, noted in reasoning
|
||
|
|
entry_price = yes_price
|
||
|
|
estimated_prob = 0.5 # Irrelevant for arb, size differently
|
||
|
|
elif best["direction"] == "YES":
|
||
|
|
side = "YES"
|
||
|
|
entry_price = yes_price
|
||
|
|
estimated_prob = min(yes_price + best["edge"], 0.95)
|
||
|
|
else:
|
||
|
|
side = "NO"
|
||
|
|
entry_price = no_price
|
||
|
|
estimated_prob = min(no_price + best["edge"], 0.95)
|
||
|
|
|
||
|
|
# Calculate confidence (0-1)
|
||
|
|
if best["type"] == "arbitrage":
|
||
|
|
confidence = min(best["edge"] / 0.05, 1.0) # 5% edge = max confidence
|
||
|
|
elif best["type"] == "momentum":
|
||
|
|
confidence = best.get("strength", 0.5)
|
||
|
|
else:
|
||
|
|
confidence = min(best["edge"] / 0.10, 0.9)
|
||
|
|
|
||
|
|
confidence = max(DEFAULT_MIN_CONFIDENCE, min(confidence, 1.0))
|
||
|
|
|
||
|
|
# Position sizing via half-Kelly
|
||
|
|
if best["type"] == "arbitrage":
|
||
|
|
# For arb, size is based on guaranteed return
|
||
|
|
kelly_frac = min(best["edge"] * 2, DEFAULT_MAX_POSITION_PCT)
|
||
|
|
else:
|
||
|
|
kelly_frac = kelly_half(estimated_prob, entry_price, side)
|
||
|
|
|
||
|
|
position_size_usdc = portfolio["value"] * kelly_frac
|
||
|
|
|
||
|
|
# Apply hard caps
|
||
|
|
max_pct = DEFAULT_MAX_POSITION_PCT
|
||
|
|
if best["type"] == "arbitrage":
|
||
|
|
max_pct = 0.20 # Higher cap for hedged arb
|
||
|
|
elif confidence < 0.7:
|
||
|
|
max_pct = 0.05
|
||
|
|
elif best["type"] == "momentum":
|
||
|
|
max_pct = 0.05 # News-like, capped lower
|
||
|
|
position_size_usdc = min(position_size_usdc, portfolio["value"] * max_pct)
|
||
|
|
|
||
|
|
# Minimum trade size
|
||
|
|
if position_size_usdc < 10:
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Risk check
|
||
|
|
passed, reason = check_risk_rules(portfolio, position_size_usdc, confidence)
|
||
|
|
if not passed:
|
||
|
|
return {
|
||
|
|
"market": market["question"],
|
||
|
|
"skipped": True,
|
||
|
|
"skip_reason": reason,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Stop loss and target
|
||
|
|
if best["type"] == "arbitrage":
|
||
|
|
target = 1.0
|
||
|
|
stop_loss = None # Arb is held to resolution
|
||
|
|
else:
|
||
|
|
target = entry_price + best["edge"] * 0.8
|
||
|
|
stop_loss = entry_price - best["edge"] * 0.5
|
||
|
|
target = round(min(target, 0.99), 4)
|
||
|
|
stop_loss = round(max(stop_loss, 0.01), 4)
|
||
|
|
|
||
|
|
ev = best["edge"] * position_size_usdc
|
||
|
|
risk_amount = (entry_price - (stop_loss or 0)) * (position_size_usdc / entry_price) if stop_loss else 0
|
||
|
|
reward_amount = (target - entry_price) * (position_size_usdc / entry_price)
|
||
|
|
risk_reward = reward_amount / risk_amount if risk_amount > 0 else float("inf")
|
||
|
|
|
||
|
|
return {
|
||
|
|
"market": market["question"],
|
||
|
|
"url": f"https://polymarket.com/event/{market['slug']}",
|
||
|
|
"side": side,
|
||
|
|
"token_id": market["token_ids"][0 if side == "YES" else 1],
|
||
|
|
"entry_price": round(entry_price, 4),
|
||
|
|
"size_usdc": round(position_size_usdc, 2),
|
||
|
|
"shares": round(position_size_usdc / entry_price, 2) if entry_price > 0 else 0,
|
||
|
|
"confidence": round(confidence, 3),
|
||
|
|
"edge_type": best["type"],
|
||
|
|
"edge": round(best["edge"], 4),
|
||
|
|
"reasoning": best["detail"],
|
||
|
|
"target": target,
|
||
|
|
"stop_loss": stop_loss,
|
||
|
|
"expected_value": round(ev, 2),
|
||
|
|
"risk_reward": round(risk_reward, 2) if risk_reward != float("inf") else "inf",
|
||
|
|
"skipped": False,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Generate ranked trade recommendations for Polymarket"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--portfolio-db", type=str, default=None,
|
||
|
|
help="Path to paper trader SQLite database (default: use $10K virtual portfolio)"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--top", type=int, default=5,
|
||
|
|
help="Number of top recommendations to output (default: 5)"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--min-volume", type=float, default=DEFAULT_MIN_VOLUME,
|
||
|
|
help=f"Minimum 24h volume filter (default: {DEFAULT_MIN_VOLUME})"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--min-edge", type=float, default=DEFAULT_MIN_EDGE,
|
||
|
|
help=f"Minimum edge threshold (default: {DEFAULT_MIN_EDGE})"
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--scan-limit", type=int, default=100,
|
||
|
|
help="Number of markets to scan from Gamma API (default: 100)"
|
||
|
|
)
|
||
|
|
|
||
|
|
args = parser.parse_args()
|
||
|
|
|
||
|
|
# Load portfolio state
|
||
|
|
portfolio = load_portfolio(args.portfolio_db)
|
||
|
|
|
||
|
|
# Fetch and filter markets
|
||
|
|
try:
|
||
|
|
markets = fetch_markets(limit=args.scan_limit, min_volume=args.min_volume)
|
||
|
|
except requests.RequestException as e:
|
||
|
|
print(json.dumps({"error": f"Failed to fetch markets: {e}"}), file=sys.stderr)
|
||
|
|
sys.exit(1)
|
||
|
|
|
||
|
|
if not markets:
|
||
|
|
print(json.dumps({
|
||
|
|
"recommendations": [],
|
||
|
|
"summary": "No markets passed filters",
|
||
|
|
"markets_scanned": 0,
|
||
|
|
}, indent=2))
|
||
|
|
return
|
||
|
|
|
||
|
|
# Score each market
|
||
|
|
recommendations = []
|
||
|
|
skipped = []
|
||
|
|
for market in markets:
|
||
|
|
result = score_market(market, portfolio)
|
||
|
|
if result is None:
|
||
|
|
continue
|
||
|
|
if result.get("skipped"):
|
||
|
|
skipped.append(result)
|
||
|
|
else:
|
||
|
|
recommendations.append(result)
|
||
|
|
|
||
|
|
# Sort by expected value descending
|
||
|
|
recommendations.sort(key=lambda r: r["expected_value"], reverse=True)
|
||
|
|
|
||
|
|
# Take top N
|
||
|
|
top_recs = recommendations[:args.top]
|
||
|
|
|
||
|
|
output = {
|
||
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
|
|
"portfolio": {
|
||
|
|
"value": portfolio["value"],
|
||
|
|
"cash": portfolio["cash"],
|
||
|
|
"open_positions": portfolio["open_position_count"],
|
||
|
|
"daily_pnl": portfolio["daily_pnl"],
|
||
|
|
},
|
||
|
|
"markets_scanned": len(markets),
|
||
|
|
"opportunities_found": len(recommendations),
|
||
|
|
"skipped_risk": len(skipped),
|
||
|
|
"recommendations": top_recs,
|
||
|
|
}
|
||
|
|
|
||
|
|
if skipped:
|
||
|
|
output["skipped_trades"] = skipped[:5]
|
||
|
|
|
||
|
|
print(json.dumps(output, indent=2))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|