2026-07-04 18:31:30 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Daily Discord digest: the copyable-sharp list with 30-day conviction stats.
|
|
|
|
|
|
|
|
|
|
Replaces the retired trade-by-trade pings (archive/webhook_receiver.py and the
|
|
|
|
|
copybot placement alerts, removed 2026-07-04): ONE message per daily run,
|
|
|
|
|
listing every wallet in watch_sharps.json with a Polymarket profile link and
|
|
|
|
|
its 30-day conviction win %, record, and conviction P&L — the same conv30_*
|
|
|
|
|
fields the dashboard renders (the wallet's own position stats, not the copy
|
|
|
|
|
book). Links require an embed: Discord ignores markdown links in plain
|
|
|
|
|
`content` messages.
|
|
|
|
|
|
|
|
|
|
Webhook: `daily_webhook` in the gitignored repo-root config.json.
|
|
|
|
|
|
|
|
|
|
python3 discord_daily.py "feed pushed" # optional arg shown in the footer
|
|
|
|
|
"""
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import ssl
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
MAX_ROWS = 30 # embed description caps at 4096 chars; 30 rows fits
|
|
|
|
|
|
|
|
|
|
|
2026-07-20 13:19:57 -04:00
|
|
|
def _post(hook, payload, tries=6):
|
|
|
|
|
"""POST with retry+backoff on ANY error (2026-07-20: the DIGEST send — not
|
|
|
|
|
just the start ping — was dying on the post-wake DNS gap / transient network
|
|
|
|
|
blips with no retry, so the whole daily digest silently never arrived).
|
|
|
|
|
Backoff 5,10,20,40,80s ≈ 2.5min of coverage; raises only if all tries fail."""
|
2026-07-06 13:22:05 -04:00
|
|
|
req = urllib.request.Request(
|
|
|
|
|
hook, data=json.dumps(payload).encode(),
|
|
|
|
|
headers={"Content-Type": "application/json",
|
|
|
|
|
"User-Agent": "Mozilla/5.0"}) # Discord 403s the default UA
|
2026-07-20 13:19:57 -04:00
|
|
|
for attempt in range(tries):
|
|
|
|
|
try:
|
|
|
|
|
urllib.request.urlopen(req, timeout=15,
|
|
|
|
|
context=ssl._create_unverified_context())
|
|
|
|
|
return
|
|
|
|
|
except Exception:
|
|
|
|
|
if attempt == tries - 1:
|
|
|
|
|
raise
|
|
|
|
|
time.sleep(5 * (2 ** attempt))
|
2026-07-06 13:22:05 -04:00
|
|
|
|
|
|
|
|
|
2026-07-04 18:31:30 -04:00
|
|
|
def main():
|
|
|
|
|
try:
|
|
|
|
|
hook = json.load(open(os.path.join(HERE, "..", "config.json"))).get("daily_webhook")
|
|
|
|
|
except Exception:
|
|
|
|
|
hook = None
|
|
|
|
|
if not hook:
|
|
|
|
|
print("[discord] no daily_webhook in ../config.json — skipping")
|
|
|
|
|
return
|
2026-07-06 13:22:05 -04:00
|
|
|
# --ping "text": one plain status line (used by daily.sh's start-of-run
|
|
|
|
|
# heads-up so the digest hours later isn't the only sign of life)
|
|
|
|
|
if len(sys.argv) > 2 and sys.argv[1] == "--ping":
|
2026-07-07 10:59:05 -04:00
|
|
|
# launchd WAKES the Mac to run daily.sh, and this heads-up ping is its
|
|
|
|
|
# first network call — DNS/WiFi is often not up yet in the first seconds
|
|
|
|
|
# after wake, so a single try fails instantly with getaddrinfo "nodename
|
|
|
|
|
# nor servname" (harmless: the end-of-run digest lands fine). Retry a few
|
|
|
|
|
# times with backoff to ride out the post-wake network gap.
|
2026-07-20 13:19:57 -04:00
|
|
|
try:
|
|
|
|
|
_post(hook, {"content": sys.argv[2]}) # _post now retries internally
|
|
|
|
|
print("[discord] ping sent")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print("[discord] ping failed after retries:", e)
|
2026-07-06 13:22:05 -04:00
|
|
|
return
|
2026-07-04 18:31:30 -04:00
|
|
|
try:
|
|
|
|
|
sharps = json.load(open(os.path.join(HERE, "watch_sharps.json")))
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[discord] watch_sharps.json unreadable ({e}) — skipping")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
sharps.sort(key=lambda s: s.get("conv30_pnl") or 0, reverse=True)
|
|
|
|
|
lines = []
|
|
|
|
|
for i, s in enumerate(sharps[:MAX_ROWS], 1):
|
|
|
|
|
name = (s.get("name") or s["wallet"][:10])
|
|
|
|
|
name = name.replace("[", "(").replace("]", ")")[:24] # keep the md link intact
|
|
|
|
|
url = "https://polymarket.com/profile/" + s["wallet"]
|
|
|
|
|
if s.get("conv30_win") is None:
|
|
|
|
|
stats = "30D: no conviction bets"
|
|
|
|
|
else:
|
|
|
|
|
stats = (f"**{s['conv30_win']:.0f}%** · "
|
|
|
|
|
f"{s.get('conv30_won', 0)}-{s.get('conv30_lost', 0)} · "
|
|
|
|
|
f"**${s.get('conv30_pnl', 0):+,.0f}**")
|
|
|
|
|
lines.append(f"`{i:>2}` [{name}]({url}) — {stats}")
|
|
|
|
|
|
|
|
|
|
embed = {
|
|
|
|
|
"title": f"Daily sharps · {len(sharps)} copyable · {time.strftime('%Y-%m-%d')}",
|
|
|
|
|
"description": "\n".join(lines)[:4000],
|
|
|
|
|
"color": 0x3FD17A,
|
|
|
|
|
"footer": {"text": ((sys.argv[1] + " · ") if len(sys.argv) > 1 else "")
|
|
|
|
|
+ "30D win% · conviction record · conviction P&L (wallet's own)"},
|
|
|
|
|
}
|
|
|
|
|
try:
|
2026-07-06 13:22:05 -04:00
|
|
|
_post(hook, {"embeds": [embed]})
|
2026-07-04 18:31:30 -04:00
|
|
|
print(f"[discord] daily sharp digest sent ({min(len(sharps), MAX_ROWS)} rows)")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print("[discord] digest failed:", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|