feat(x): viral live-prediction X auto-poster for Trump signals
When Trump posts an actionable buy/short signal, instantly tweet the call (entry/direction, hard time window, "ratio me" hook), then post a result follow-up X_FOLLOWUP_MINUTES later reporting the realised 15-min move plus a recent hit-rate brag with the site link. - New app/services/x_poster.py: OAuth 1.0a signed (stdlib hmac, no new dep, httpx reused). Fire-and-forget, fully degradable — no-op unless X creds + x_enabled. Dry-run logs tweet text. In-memory UTC-daily cap. 280-char hard cap on all templates (no Premium). - Gated to source=truth buy/short above x_min_confidence; scanner signals excluded (don't fit the "Trump just posted" format). - Hooked next to notify_signal in /signals/ingest + both Trump pollers (dedup via unique external_id → single tweet per post). - config.py + .env.example: X_API_KEY/SECRET, X_ACCESS_TOKEN/SECRET, X_ENABLED, X_MIN_CONFIDENCE, X_DAILY_CAP, X_FOLLOWUP_MINUTES, X_LINK_URL. 72 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,3 +69,24 @@ HL_MAINNET=true
|
|||||||
# auto-runs create_all() on startup (Alembic bypass — DEV ONLY).
|
# auto-runs create_all() on startup (Alembic bypass — DEV ONLY).
|
||||||
# production → both are off; schema strictly Alembic-managed.
|
# production → both are off; schema strictly Alembic-managed.
|
||||||
ENVIRONMENT=production
|
ENVIRONMENT=production
|
||||||
|
|
||||||
|
# ── X (Twitter) auto-poster ───────────────────────────────────────────────────
|
||||||
|
# Viral "live prediction" tweets: when Trump posts an actionable signal, the bot
|
||||||
|
# tweets the call instantly, then posts a result follow-up X_FOLLOWUP_MINUTES later.
|
||||||
|
# OAuth 1.0a User Context. From developer.x.com → your App → "Keys and tokens".
|
||||||
|
# ⚠️ App permissions MUST be "Read and Write" — then REGENERATE the access token.
|
||||||
|
# ⚠️ Do NOT use Bearer Token / Client ID / Client Secret (those are read/OAuth2).
|
||||||
|
X_API_KEY=
|
||||||
|
X_API_SECRET=
|
||||||
|
X_ACCESS_TOKEN=
|
||||||
|
X_ACCESS_SECRET=
|
||||||
|
# Master switch. false = dry-run (logs tweet text, sends nothing) even with keys set.
|
||||||
|
X_ENABLED=false
|
||||||
|
# Only tweet when ai_confidence >= this (0–100).
|
||||||
|
X_MIN_CONFIDENCE=60
|
||||||
|
# Hard daily tweet cap (initial + follow-ups), resets at UTC midnight.
|
||||||
|
X_DAILY_CAP=40
|
||||||
|
# Minutes after the prediction tweet to post the result follow-up.
|
||||||
|
X_FOLLOWUP_MINUTES=15
|
||||||
|
# Public URL appended to the follow-up tweet (defaults to FRONTEND_URL if empty).
|
||||||
|
X_LINK_URL=
|
||||||
|
|||||||
@@ -219,6 +219,14 @@ async def ingest_signal(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Telegram notify failed for signal %d: %s", post.id, exc)
|
logger.warning("Telegram notify failed for signal %d: %s", post.id, exc)
|
||||||
|
|
||||||
|
# Auto-post a viral "live prediction" tweet (fire-and-forget; no-op unless
|
||||||
|
# X is configured + enabled). Internally gated to source=truth buy/short.
|
||||||
|
try:
|
||||||
|
from app.services.x_poster import notify_x_signal
|
||||||
|
notify_x_signal(post)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("X notify failed for signal %d: %s", post.id, exc)
|
||||||
|
|
||||||
return SignalIngestResponse(status="accepted", post_id=post.id)
|
return SignalIngestResponse(status="accepted", post_id=post.id)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,32 @@ class Settings(BaseSettings):
|
|||||||
# (no trading details, no /adopt CTA). Leave empty to disable.
|
# (no trading details, no /adopt CTA). Leave empty to disable.
|
||||||
telegram_public_channel_id: str = ""
|
telegram_public_channel_id: str = ""
|
||||||
|
|
||||||
|
# ── X (Twitter) auto-poster ──────────────────────────────────────────────
|
||||||
|
# OAuth 1.0a User Context — for posting from a single fixed bot account
|
||||||
|
# (@TrumpAlpha_signals). Get all four from developer.x.com → your App →
|
||||||
|
# "Keys and tokens". App permissions MUST be "Read and Write", and the
|
||||||
|
# Access Token/Secret must be regenerated AFTER setting write permission.
|
||||||
|
# Bearer Token / Client ID / Client Secret are NOT used (those are read /
|
||||||
|
# OAuth2 web-flow credentials). Empty = X posting disabled (no-op).
|
||||||
|
x_api_key: str = "" # = Consumer Key
|
||||||
|
x_api_secret: str = "" # = Consumer Secret
|
||||||
|
x_access_token: str = ""
|
||||||
|
x_access_secret: str = ""
|
||||||
|
# Master switch. Even with keys set, posting stays off unless this is true.
|
||||||
|
# Lets you load creds without going live (dry-run = logs the tweet text).
|
||||||
|
x_enabled: bool = False
|
||||||
|
# Only post when ai_confidence >= this (0–100). Keeps low-quality noise off
|
||||||
|
# the public timeline. Scanner signals carry their own score.
|
||||||
|
x_min_confidence: int = 60
|
||||||
|
# Hard daily cap on tweets (initial + follow-ups counted together) to stay
|
||||||
|
# under Free-tier 1,500/mo and avoid spam flags. In-memory, resets at UTC
|
||||||
|
# midnight. Single-process by design so a plain counter is safe.
|
||||||
|
x_daily_cap: int = 40
|
||||||
|
# Minutes after the initial prediction tweet to post the result follow-up.
|
||||||
|
x_followup_minutes: int = 15
|
||||||
|
# Public site URL appended to the follow-up tweet. Falls back to frontend_url.
|
||||||
|
x_link_url: str = ""
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ async def poll_trumpstruth(db_session_factory) -> None:
|
|||||||
notify_signal(post)
|
notify_signal(post)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Telegram notify failed for post %d: %s", post.id, exc)
|
logger.warning("Telegram notify failed for post %d: %s", post.id, exc)
|
||||||
|
try:
|
||||||
|
from app.services.x_poster import notify_x_signal
|
||||||
|
notify_x_signal(post)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("X notify failed for post %d: %s", post.id, exc)
|
||||||
try:
|
try:
|
||||||
from app.services.bot_engine import process_post
|
from app.services.bot_engine import process_post
|
||||||
await process_post(post, db)
|
await process_post(post, db)
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ async def poll_truth_social(db_session_factory) -> None:
|
|||||||
notify_signal(post)
|
notify_signal(post)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Telegram notify failed for post %d: %s", post.id, exc)
|
logger.warning("Telegram notify failed for post %d: %s", post.id, exc)
|
||||||
|
try:
|
||||||
|
from app.services.x_poster import notify_x_signal
|
||||||
|
notify_x_signal(post)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("X notify failed for post %d: %s", post.id, exc)
|
||||||
try:
|
try:
|
||||||
from app.services.bot_engine import process_post
|
from app.services.bot_engine import process_post
|
||||||
await process_post(post, db)
|
await process_post(post, db)
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
X (Twitter) auto-poster — viral "live prediction" tweets for actionable signals.
|
||||||
|
|
||||||
|
Mirrors the design of telegram.py: fire-and-forget, fully degradable. If X
|
||||||
|
creds are missing or `x_enabled` is False, every entry point becomes a no-op
|
||||||
|
(dry-run logs the tweet text but sends nothing). A signal MUST never be blocked
|
||||||
|
by an X API failure.
|
||||||
|
|
||||||
|
notify_x_signal(post) ← call right after notify_signal(post) in the ingest path
|
||||||
|
|
||||||
|
Flow per actionable signal:
|
||||||
|
1. Immediately post the PREDICTION tweet (entry / TP / SL, hard time window).
|
||||||
|
2. Schedule a FOLLOW-UP tweet `x_followup_minutes` later that reports the
|
||||||
|
actual move (price_impact_m15) — the "told you" brag that drives the link.
|
||||||
|
|
||||||
|
Auth: OAuth 1.0a User Context, signed by hand with stdlib hmac/hashlib (no extra
|
||||||
|
dependency — httpx is already vendored). Posting uses POST /2/tweets.
|
||||||
|
|
||||||
|
Tone: deliberately provocative / "黑红" — invites ratios, taunts CT, no soft CTA.
|
||||||
|
All templates are hard-capped at 280 chars (no X Premium = 280 limit).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import AsyncSessionLocal as async_session
|
||||||
|
from app.models import Post
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TWEET_URL = "https://api.twitter.com/2/tweets"
|
||||||
|
MAX_TWEET = 280
|
||||||
|
|
||||||
|
# In-memory daily cap counter. Single-process by design (see main.py leader
|
||||||
|
# guard), so a plain dict is safe. Resets when the UTC date rolls over.
|
||||||
|
_sent_today = 0
|
||||||
|
_sent_date: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── credential / enablement gate ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_ok() -> bool:
|
||||||
|
return bool(
|
||||||
|
settings.x_api_key
|
||||||
|
and settings.x_api_secret
|
||||||
|
and settings.x_access_token
|
||||||
|
and settings.x_access_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _under_daily_cap() -> bool:
|
||||||
|
"""True if we still have budget today. Increments are done by _record_sent."""
|
||||||
|
global _sent_today, _sent_date
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
if today != _sent_date:
|
||||||
|
_sent_date = today
|
||||||
|
_sent_today = 0
|
||||||
|
return _sent_today < settings.x_daily_cap
|
||||||
|
|
||||||
|
|
||||||
|
def _record_sent() -> None:
|
||||||
|
global _sent_today
|
||||||
|
_sent_today += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── OAuth 1.0a signing ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _percent(s: str) -> str:
|
||||||
|
# RFC 3986 — OAuth requires ~ unescaped and everything else strict.
|
||||||
|
return urllib.parse.quote(str(s), safe="~")
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_header(method: str, url: str) -> str:
|
||||||
|
"""Build the OAuth 1.0a Authorization header for a JSON-body request.
|
||||||
|
|
||||||
|
For POST /2/tweets the body is JSON (not form-encoded), so per the OAuth
|
||||||
|
spec the body params are NOT part of the signature base string — only the
|
||||||
|
oauth_* params are. This is the documented X v2 behaviour.
|
||||||
|
"""
|
||||||
|
oauth = {
|
||||||
|
"oauth_consumer_key": settings.x_api_key,
|
||||||
|
"oauth_nonce": secrets.token_hex(16),
|
||||||
|
"oauth_signature_method": "HMAC-SHA1",
|
||||||
|
"oauth_timestamp": str(int(time.time())),
|
||||||
|
"oauth_token": settings.x_access_token,
|
||||||
|
"oauth_version": "1.0",
|
||||||
|
}
|
||||||
|
# Signature base string
|
||||||
|
param_str = "&".join(
|
||||||
|
f"{_percent(k)}={_percent(v)}" for k, v in sorted(oauth.items())
|
||||||
|
)
|
||||||
|
base = "&".join([method.upper(), _percent(url), _percent(param_str)])
|
||||||
|
signing_key = f"{_percent(settings.x_api_secret)}&{_percent(settings.x_access_secret)}"
|
||||||
|
digest = hmac.new(
|
||||||
|
signing_key.encode(), base.encode(), hashlib.sha1
|
||||||
|
).digest()
|
||||||
|
oauth["oauth_signature"] = base64.b64encode(digest).decode()
|
||||||
|
|
||||||
|
header = "OAuth " + ", ".join(
|
||||||
|
f'{_percent(k)}="{_percent(v)}"' for k, v in sorted(oauth.items())
|
||||||
|
)
|
||||||
|
return header
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_tweet(text: str, reply_to: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""POST a single tweet. Returns the new tweet id, or None on failure / dry-run.
|
||||||
|
|
||||||
|
`reply_to` chains the follow-up under the prediction tweet (a thread), which
|
||||||
|
reads better and keeps the brag attached to the original call.
|
||||||
|
"""
|
||||||
|
text = text[:MAX_TWEET]
|
||||||
|
|
||||||
|
if not settings.x_enabled:
|
||||||
|
logger.info("[x_poster] DRY-RUN (x_enabled=False), would tweet:\n%s", text)
|
||||||
|
return None
|
||||||
|
if not _creds_ok():
|
||||||
|
logger.warning("[x_poster] missing X creds — skipping tweet")
|
||||||
|
return None
|
||||||
|
if not _under_daily_cap():
|
||||||
|
logger.warning("[x_poster] daily cap (%d) reached — skipping tweet",
|
||||||
|
settings.x_daily_cap)
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload: dict = {"text": text}
|
||||||
|
if reply_to:
|
||||||
|
payload["reply"] = {"in_reply_to_tweet_id": reply_to}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": _oauth_header("POST", TWEET_URL),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.post(TWEET_URL, json=payload, headers=headers)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
_record_sent()
|
||||||
|
tid = resp.json().get("data", {}).get("id")
|
||||||
|
logger.info("[x_poster] tweeted id=%s (%d/%d today)",
|
||||||
|
tid, _sent_today, settings.x_daily_cap)
|
||||||
|
return tid
|
||||||
|
logger.warning("[x_poster] tweet failed %s: %s",
|
||||||
|
resp.status_code, resp.text[:300])
|
||||||
|
return None
|
||||||
|
except Exception as exc: # noqa: BLE001 — never let X break ingestion
|
||||||
|
logger.warning("[x_poster] tweet exception: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── win-rate helper (for the brag in the follow-up) ────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _recent_hit_rate(db) -> tuple[int, Optional[float]]:
|
||||||
|
"""(count, hit_rate_pct) over recent scored directional posts.
|
||||||
|
|
||||||
|
A 'hit' = the realised 15-min move agreed with the call direction
|
||||||
|
(buy → up, short → down). Cheap, audit-friendly, and honest: it reads the
|
||||||
|
same price_impact_m15 we already record. Returns (n, None) if too few.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Post.signal, Post.price_impact_m15)
|
||||||
|
.where(
|
||||||
|
Post.signal.in_(("buy", "short")),
|
||||||
|
Post.price_impact_m15.isnot(None),
|
||||||
|
)
|
||||||
|
.order_by(Post.id.desc())
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
n = len(rows)
|
||||||
|
if n < 5:
|
||||||
|
return n, None
|
||||||
|
hits = 0
|
||||||
|
for sig, move in rows:
|
||||||
|
if move is None:
|
||||||
|
continue
|
||||||
|
if (sig == "buy" and move > 0) or (sig == "short" and move < 0):
|
||||||
|
hits += 1
|
||||||
|
return n, round(100.0 * hits / n, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── tweet templates (≤280, 黑红 tone) ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _dir_word(signal: Optional[str]) -> str:
|
||||||
|
return "LONG" if signal == "buy" else "SHORT" if signal == "short" else "WATCH"
|
||||||
|
|
||||||
|
|
||||||
|
def format_prediction(post: Post) -> str:
|
||||||
|
"""The instant 'Trump just posted, here's my call' tweet."""
|
||||||
|
asset = (post.target_asset or "BTC").upper()
|
||||||
|
d = _dir_word(post.signal)
|
||||||
|
entry = post.price_at_post
|
||||||
|
mins = settings.x_followup_minutes
|
||||||
|
|
||||||
|
entry_line = f"Entry ~${entry:,.0f}\n" if entry else ""
|
||||||
|
# Hard, falsifiable, time-boxed → invites screenshots and ratios.
|
||||||
|
text = (
|
||||||
|
f"Trump just posted. 🟠\n\n"
|
||||||
|
f"{asset}: {d} — moves within {mins} min.\n"
|
||||||
|
f"{entry_line}"
|
||||||
|
f"\nScreenshot this. Wrong? Ratio me.\n"
|
||||||
|
f"Right? You already know. ⏰"
|
||||||
|
)
|
||||||
|
return text[:MAX_TWEET]
|
||||||
|
|
||||||
|
|
||||||
|
def format_followup(post: Post, move_pct: Optional[float],
|
||||||
|
n: int, hit_rate: Optional[float]) -> str:
|
||||||
|
"""The '15 min later, told you' brag. Drives the only link we ever post."""
|
||||||
|
asset = (post.target_asset or "BTC").upper()
|
||||||
|
mins = settings.x_followup_minutes
|
||||||
|
link = (settings.x_link_url or settings.frontend_url or "").rstrip("/")
|
||||||
|
|
||||||
|
if move_pct is None:
|
||||||
|
result = f"{asset}: still cooking. Check the board 👇"
|
||||||
|
else:
|
||||||
|
arrow = "✅" if (
|
||||||
|
(post.signal == "buy" and move_pct > 0)
|
||||||
|
or (post.signal == "short" and move_pct < 0)
|
||||||
|
) else "🤡"
|
||||||
|
result = f"{asset}: {move_pct:+.1f}% {arrow}"
|
||||||
|
|
||||||
|
rate_line = ""
|
||||||
|
if hit_rate is not None:
|
||||||
|
rate_line = f"\n{hit_rate:.0f}% hit rate over {n} calls. No CT badge needed."
|
||||||
|
|
||||||
|
tail = f"\n{link}" if link else ""
|
||||||
|
text = f"{mins} min later.\n\n{result}{rate_line}\n\nBookmarked yet?{tail}"
|
||||||
|
return text[:MAX_TWEET]
|
||||||
|
|
||||||
|
|
||||||
|
# ── dispatch ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _x_dispatch(post_id: int) -> None:
|
||||||
|
"""Post the prediction now, then schedule the follow-up brag."""
|
||||||
|
async with async_session() as db:
|
||||||
|
post = await db.get(Post, post_id)
|
||||||
|
if not post:
|
||||||
|
return
|
||||||
|
# Only post Trump-sourced actionable calls — that's the viral hook.
|
||||||
|
# (BTC/funding scanner signals are slow-burn; they don't fit the
|
||||||
|
# "Trump just posted, watch this" format.)
|
||||||
|
if post.source != "truth" or post.signal not in ("buy", "short"):
|
||||||
|
return
|
||||||
|
if post.ai_confidence and post.ai_confidence < settings.x_min_confidence:
|
||||||
|
logger.info("[x_poster] post=%d conf %s < %d — skipping",
|
||||||
|
post_id, post.ai_confidence, settings.x_min_confidence)
|
||||||
|
return
|
||||||
|
|
||||||
|
pred_text = format_prediction(post)
|
||||||
|
|
||||||
|
tweet_id = await _post_tweet(pred_text)
|
||||||
|
|
||||||
|
# Schedule the follow-up. Fire-and-forget asyncio task (single process by
|
||||||
|
# design). A restart within the window loses the pending follow-up — that's
|
||||||
|
# acceptable for a marketing post; the signal itself is already safe.
|
||||||
|
delay = max(0, settings.x_followup_minutes) * 60
|
||||||
|
asyncio.create_task(_followup_later(post_id, tweet_id, delay))
|
||||||
|
|
||||||
|
|
||||||
|
async def _followup_later(post_id: int, reply_to: Optional[str], delay: int) -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
async with async_session() as db:
|
||||||
|
post = await db.get(Post, post_id)
|
||||||
|
if not post:
|
||||||
|
return
|
||||||
|
move = post.price_impact_m15
|
||||||
|
n, rate = await _recent_hit_rate(db)
|
||||||
|
text = format_followup(post, move, n, rate)
|
||||||
|
await _post_tweet(text, reply_to=reply_to)
|
||||||
|
except asyncio.CancelledError: # shutdown — drop quietly
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("[x_poster] follow-up failed post=%d: %s", post_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_x_signal(post: Post) -> None:
|
||||||
|
"""Fire-and-forget entry point. Mirrors telegram.notify_signal. Safe to call
|
||||||
|
unconditionally — degrades to a no-op when X is disabled/unconfigured."""
|
||||||
|
if not _creds_ok() and not settings.x_enabled:
|
||||||
|
# Neither configured nor in dry-run intent → silent skip.
|
||||||
|
return
|
||||||
|
if not post or not post.id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
asyncio.create_task(_x_dispatch(post.id))
|
||||||
|
except RuntimeError:
|
||||||
|
logger.warning("[x_poster] no running event loop, skipping post=%s", post.id)
|
||||||
Reference in New Issue
Block a user