feat: add daily budget, active window, trade snapshots, and price impact monitor

- New migrations for daily_budget, active_window, and bottrade snapshot
- Add trumpstruth scraper and price_impact_monitor service
- Expand bot_engine, hyperliquid, recovery, and tp_sl_monitor logic
- Update API/schemas/models for new features

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-04-25 16:04:49 +08:00
parent a2c68e2939
commit 4ffcb442fe
20 changed files with 1110 additions and 114 deletions
+25
View File
@@ -0,0 +1,25 @@
"""Add daily_budget_usd to subscriptions
Revision ID: 003
Revises: 002
Create Date: 2026-04-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.add_column(sa.Column("daily_budget_usd", sa.Float(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.drop_column("daily_budget_usd")
+27
View File
@@ -0,0 +1,27 @@
"""Add active_from/active_until to subscriptions
Revision ID: 004
Revises: 003
Create Date: 2026-04-22 01:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.add_column(sa.Column("active_from", sa.DateTime(), nullable=True))
batch_op.add_column(sa.Column("active_until", sa.DateTime(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("subscriptions") as batch_op:
batch_op.drop_column("active_until")
batch_op.drop_column("active_from")
+27
View File
@@ -0,0 +1,27 @@
"""Snapshot size_usd / leverage on BotTrade
Revision ID: 005
Revises: 004
Create Date: 2026-04-23 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table("bot_trades") as batch_op:
batch_op.add_column(sa.Column("size_usd", sa.Float(), nullable=True))
batch_op.add_column(sa.Column("leverage", sa.Integer(), nullable=True))
def downgrade() -> None:
with op.batch_alter_table("bot_trades") as batch_op:
batch_op.drop_column("leverage")
batch_op.drop_column("size_usd")
+14 -6
View File
@@ -26,15 +26,23 @@ def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[
def _post_to_schema(post: Post) -> TrumpPost: def _post_to_schema(post: Post) -> TrumpPost:
price_impact: Optional[PriceImpact] = None price_impact: Optional[PriceImpact] = None
if post.price_impact_asset and post.price_at_post is not None: if post.price_impact_asset and post.price_at_post is not None:
# Overlay live rolling peaks for windows that haven't closed yet
from app.services.price_impact_monitor import get_live_impact
live = get_live_impact(post.id) or {}
m5 = live.get("price_impact_m5", post.price_impact_m5)
m15 = live.get("price_impact_m15", post.price_impact_m15)
m1h = live.get("price_impact_m1h", post.price_impact_m1h)
price_impact = PriceImpact( price_impact = PriceImpact(
asset=post.price_impact_asset, asset=post.price_impact_asset,
m5=post.price_impact_m5 or 0.0, m5=m5,
m15=post.price_impact_m15 or 0.0, m15=m15,
m1h=post.price_impact_m1h or 0.0, m1h=m1h,
price_at_post=post.price_at_post, price_at_post=post.price_at_post,
correct_m5=_direction_correct(post.signal, post.price_impact_m5), correct_m5=_direction_correct(post.signal, m5),
correct_m15=_direction_correct(post.signal, post.price_impact_m15), correct_m15=_direction_correct(post.signal, m15),
correct_m1h=_direction_correct(post.signal, post.price_impact_m1h), correct_m1h=_direction_correct(post.signal, m1h),
) )
return TrumpPost( return TrumpPost(
id=post.id, id=post.id,
+50 -7
View File
@@ -1,6 +1,6 @@
import logging import logging
from fastapi import APIRouter, Depends, Header, HTTPException, Query from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -150,6 +150,9 @@ async def get_user(
take_profit_pct=sub.take_profit_pct, take_profit_pct=sub.take_profit_pct,
stop_loss_pct=sub.stop_loss_pct, stop_loss_pct=sub.stop_loss_pct,
min_confidence=sub.min_confidence, min_confidence=sub.min_confidence,
daily_budget_usd=sub.daily_budget_usd,
active_from=iso_utc(sub.active_from),
active_until=iso_utc(sub.active_until),
), ),
) )
@@ -157,10 +160,20 @@ async def get_user(
@router.put("/user/{wallet}/settings", response_model=UserSettings) @router.put("/user/{wallet}/settings", response_model=UserSettings)
async def set_user_settings( async def set_user_settings(
wallet: str, wallet: str,
body: SetSettingsRequest, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wallet = wallet.lower().strip() wallet = wallet.lower().strip()
# Read raw JSON FIRST — the frontend signs the payload exactly as it's
# serialized in JSON (e.g. `5` stays `5`, not `5.0`). If we let Pydantic
# coerce ints→floats first, the server-side hash won't match the client's.
raw = await request.json()
raw_settings = raw.get("settings")
if not isinstance(raw_settings, dict):
raise HTTPException(422, "Missing settings block")
# Now validate with Pydantic
body = SetSettingsRequest(**raw)
if wallet != body.wallet.lower().strip(): if wallet != body.wallet.lower().strip():
raise HTTPException(400, "Wallet mismatch") raise HTTPException(400, "Wallet mismatch")
@@ -169,19 +182,46 @@ async def set_user_settings(
raise HTTPException(422, "leverage must be 150") raise HTTPException(422, "leverage must be 150")
if not (5 <= s.position_size_usd <= 10000): if not (5 <= s.position_size_usd <= 10000):
raise HTTPException(422, "position_size_usd must be $5$10,000") raise HTTPException(422, "position_size_usd must be $5$10,000")
if s.take_profit_pct is not None and not (0.1 <= s.take_profit_pct <= 50): # Take-profit, stop-loss and daily budget are now MANDATORY — the bot won't
raise HTTPException(422, "take_profit_pct must be 0.150") # run without them, so we reject null here and force the client to supply values.
if s.stop_loss_pct is not None and not (0.1 <= s.stop_loss_pct <= 50): if s.take_profit_pct is None or not (0.1 <= s.take_profit_pct <= 50):
raise HTTPException(422, "stop_loss_pct must be 0.150") raise HTTPException(422, "take_profit_pct is required (0.150)")
if s.stop_loss_pct is None or not (0.1 <= s.stop_loss_pct <= 50):
raise HTTPException(422, "stop_loss_pct is required (0.150)")
if not (0 <= s.min_confidence <= 100): if not (0 <= s.min_confidence <= 100):
raise HTTPException(422, "min_confidence must be 0100") raise HTTPException(422, "min_confidence must be 0100")
if s.daily_budget_usd is None or not (0 < s.daily_budget_usd <= 100000):
raise HTTPException(422, "daily_budget_usd is required (>0, ≤100,000)")
# Parse schedule (ISO strings → naive-UTC datetimes). Either both or neither.
from datetime import datetime as _dt, timezone as _tz
def _parse_iso_utc(v):
if v is None:
return None
try:
# Accept "...Z" or "+00:00" forms
dt = _dt.fromisoformat(v.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.astimezone(_tz.utc).replace(tzinfo=None)
return dt
except Exception:
raise HTTPException(422, f"invalid ISO datetime: {v!r}")
af = _parse_iso_utc(s.active_from)
au = _parse_iso_utc(s.active_until)
if (af is None) != (au is None):
raise HTTPException(422, "active_from and active_until must be set together or both empty")
if af is not None and au is not None and au <= af:
raise HTTPException(422, "active_until must be after active_from")
# Hash the RAW dict (matches what frontend signed)
verify_signed_request( verify_signed_request(
action=ACTION_SET_SETTINGS, action=ACTION_SET_SETTINGS,
wallet=wallet, wallet=wallet,
timestamp_ms=body.timestamp, timestamp_ms=body.timestamp,
signature=body.signature, signature=body.signature,
body=s.model_dump(), body=raw_settings,
) )
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet)) result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
@@ -194,6 +234,9 @@ async def set_user_settings(
sub.take_profit_pct = s.take_profit_pct sub.take_profit_pct = s.take_profit_pct
sub.stop_loss_pct = s.stop_loss_pct sub.stop_loss_pct = s.stop_loss_pct
sub.min_confidence = s.min_confidence sub.min_confidence = s.min_confidence
sub.daily_budget_usd = s.daily_budget_usd
sub.active_from = af
sub.active_until = au
await db.commit() await db.commit()
logger.info("Settings updated for %s: %s", wallet, s.model_dump()) logger.info("Settings updated for %s: %s", wallet, s.model_dump())
return s return s
+94 -1
View File
@@ -10,6 +10,7 @@ from app.config import settings
from app.database import AsyncSessionLocal, engine from app.database import AsyncSessionLocal, engine
from app.models import Base from app.models import Base
from app.scrapers.truth_social import poll_truth_social, backfill_history from app.scrapers.truth_social import poll_truth_social, backfill_history
from app.scrapers.trumpstruth import poll_trumpstruth
from app.services.binance import run_binance_ws from app.services.binance import run_binance_ws
from app.ws.manager import manager from app.ws.manager import manager
@@ -64,9 +65,24 @@ async def lifespan(app: FastAPI):
max_instances=1, max_instances=1,
coalesce=True, coalesce=True,
) )
# Fallback poller — trumpstruth.org RSS. Same Truth Social originalId =
# same external_id hash, so dedup is automatic. Whoever sees the post
# first wins. Offset by half the interval so the two pollers don't
# hammer the network at the same instant.
_scheduler.add_job(
poll_trumpstruth,
"interval",
seconds=settings.truth_social_poll_seconds,
args=[AsyncSessionLocal],
id="trumpstruth_poll",
max_instances=1,
coalesce=True,
next_run_time=None, # APScheduler will pick a fresh slot
)
_scheduler.start() _scheduler.start()
logger.info( logger.info(
"Truth Social poller scheduled every %ds.", settings.truth_social_poll_seconds "Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).",
settings.truth_social_poll_seconds,
) )
yield yield
@@ -117,8 +133,85 @@ app.include_router(user_router, prefix="/api")
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
"""Shallow liveness — used by load balancers / Docker healthcheck.
Returns 200 as long as the process is alive and the event loop responds."""
return {"status": "ok"} return {"status": "ok"}
@app.get("/api/health/deep")
async def health_deep():
"""Deep healthcheck — used by external uptime monitors (UptimeRobot, etc).
Returns 503 if any of:
- DB query fails
- Scraper hasn't completed a successful poll in > 90s
(poll runs every 15s; allow 6 misses before alarm)
Body always includes diagnostic fields so the alarm payload is actionable.
"""
from datetime import datetime, timezone
from fastapi import Response
from sqlalchemy import text
from app.scrapers import truth_social as ts_scraper
from app.scrapers import trumpstruth as ts_fallback
now = datetime.now(timezone.utc)
problems: list[str] = []
# 1. DB ping
db_ok = False
db_error: Optional[str] = None
try:
async with AsyncSessionLocal() as db:
await db.execute(text("SELECT 1"))
db_ok = True
except Exception as exc:
db_error = str(exc)
problems.append(f"db: {db_error}")
# 2. Scraper liveness — system is healthy if AT LEAST ONE source polled
# recently. Both stale = real problem (network down, or both upstreams dead).
sources = [
("cnn", ts_scraper.last_successful_poll_at, ts_scraper.last_poll_error),
("trumpstruth", ts_fallback.last_successful_poll_at, ts_fallback.last_poll_error),
]
source_status = []
freshest_age: Optional[int] = None
for name, last, err in sources:
age = int((now - last).total_seconds()) if last else None
source_status.append({
"name": name,
"last_poll": last.isoformat() if last else None,
"age_sec": age,
"last_error": err,
})
if age is not None and (freshest_age is None or age < freshest_age):
freshest_age = age
if freshest_age is None:
problems.append("scrapers: no source has ever completed a poll")
elif freshest_age > 90:
# 6× the 15s interval — both sources are silent
problems.append(f"scrapers: all stale (freshest={freshest_age}s)")
body = {
"status": "ok" if not problems else "degraded",
"now": now.isoformat(),
"db_ok": db_ok,
"db_error": db_error,
"scrapers": source_status,
"freshest_age_sec": freshest_age,
"problems": problems,
}
if problems:
return Response(
content=__import__("json").dumps(body),
status_code=503,
media_type="application/json",
)
return body
if settings.environment == "development": if settings.environment == "development":
from app.api.dev import router as dev_router from app.api.dev import router as dev_router
app.include_router(dev_router, prefix="/api") app.include_router(dev_router, prefix="/api")
+10
View File
@@ -90,6 +90,11 @@ class BotTrade(Base):
opened_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) opened_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
hl_order_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True) hl_order_id: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
# Snapshot of user settings AT TIME OF ENTRY. Required so changes to
# Subscription.position_size_usd / leverage after-the-fact don't corrupt
# historical PnL or daily-budget accounting.
size_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
leverage: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
trigger_post: Mapped[Optional["Post"]] = relationship("Post", back_populates="trades") trigger_post: Mapped[Optional["Post"]] = relationship("Post", back_populates="trades")
@@ -110,3 +115,8 @@ class Subscription(Base):
take_profit_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 2.0 = close at +2% take_profit_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 2.0 = close at +2%
stop_loss_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 1.5 = close at -1.5% stop_loss_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 1.5 = close at -1.5%
min_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=80) min_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=80)
daily_budget_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # e.g. 15.0 = max $15 of new positions per UTC day
# Optional active window. Both None = always on (when Subscription.active=True).
# Both stored as naive-UTC datetimes.
active_from: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
active_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
+10 -4
View File
@@ -5,11 +5,13 @@ from pydantic import BaseModel
class PriceImpact(BaseModel): class PriceImpact(BaseModel):
asset: str asset: str
m5: float # None = window still open (live rolling peak) or no price data yet.
m15: float # Float = sealed peak move (%) in the signal direction once window closed.
m1h: float m5: Optional[float] = None
m15: Optional[float] = None
m1h: Optional[float] = None
price_at_post: float price_at_post: float
# None = outcome window not yet reached or no price data; True/False = signal direction matched actual move # None = outcome window not yet reached; True/False = signal direction matched
correct_m5: Optional[bool] = None correct_m5: Optional[bool] = None
correct_m15: Optional[bool] = None correct_m15: Optional[bool] = None
correct_m1h: Optional[bool] = None correct_m1h: Optional[bool] = None
@@ -101,6 +103,10 @@ class UserSettings(BaseModel):
take_profit_pct: Optional[float] = None take_profit_pct: Optional[float] = None
stop_loss_pct: Optional[float] = None stop_loss_pct: Optional[float] = None
min_confidence: int min_confidence: int
daily_budget_usd: Optional[float] = None
# ISO-8601 UTC strings; both None = always on (Subscription.active still gates it).
active_from: Optional[str] = None
active_until: Optional[str] = None
class SetSettingsRequest(SignedEnvelope): class SetSettingsRequest(SignedEnvelope):
+155
View File
@@ -0,0 +1,155 @@
"""
Trump Truth Social scraper — trumpstruth.org RSS fallback.
Why a second scraper?
CNN's archive (the primary source) sometimes lags 510 minutes behind real
posts. trumpstruth.org publishes an RSS feed of the same Truth Social account
that often updates faster. Running both in parallel and deduping by the
Truth Social `originalId` gives us "min(latency_a, latency_b)" — i.e. whoever
sees the post first wins.
Dedup strategy:
Both CNN and trumpstruth expose the underlying Truth Social post id. We
hash it the same way (md5(str(id))) so the second source is a no-op when
the first already inserted the row.
Source: https://www.trumpstruth.org/feed (RSS 2.0 with custom truth:originalId tag)
"""
import hashlib
import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Optional
import httpx
from app.scrapers.truth_social import _process_entry, _post_to_ws_payload
from app.ws.manager import manager
logger = logging.getLogger(__name__)
FEED_URL = "https://www.trumpstruth.org/feed"
NS = {
"atom": "http://www.w3.org/2005/Atom",
"truth": "https://truthsocial.com/ns",
}
# Liveness — read by /api/health/deep
last_successful_poll_at: Optional[datetime] = None
last_poll_error: Optional[str] = None
async def _fetch_feed() -> Optional[str]:
headers = {
"User-Agent": "Mozilla/5.0 (compatible; TrumpSignal/1.0)",
"Accept": "application/rss+xml, application/xml",
}
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
resp = await client.get(FEED_URL, headers=headers)
resp.raise_for_status()
return resp.text
except Exception as exc:
logger.warning("Failed to fetch trumpstruth.org feed: %s", exc)
return None
_HTML_TAG = re.compile(r"<[^>]+>")
def _to_cnn_shape(item: ET.Element) -> Optional[dict]:
"""Convert one <item> from the RSS feed into the dict shape the existing
`_process_entry` expects (CNN archive format).
Required output keys: id, created_at (ISO), content (HTML)."""
orig_id_el = item.find("truth:originalId", NS)
if orig_id_el is None or not (orig_id_el.text or "").strip():
return None
orig_id = orig_id_el.text.strip()
pub_el = item.find("pubDate")
if pub_el is None or not pub_el.text:
return None
try:
dt = parsedate_to_datetime(pub_el.text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
created_iso = dt.isoformat().replace("+00:00", "Z")
except Exception:
return None
desc_el = item.find("description")
content_html = (desc_el.text or "").strip() if desc_el is not None else ""
return {
"id": orig_id,
"created_at": created_iso,
"content": content_html,
}
async def poll_trumpstruth(db_session_factory) -> None:
"""One poll cycle. Called by APScheduler.
Idempotent: posts already inserted by the CNN scraper are skipped via the
`external_id` uniqueness check inside `_process_entry`.
"""
global last_successful_poll_at, last_poll_error
raw = await _fetch_feed()
if raw is None:
last_poll_error = "fetch_feed returned None"
return
try:
root = ET.fromstring(raw)
except ET.ParseError as exc:
logger.warning("trumpstruth RSS parse error: %s", exc)
last_poll_error = f"parse: {exc}"
return
items = root.findall(".//item")
if not items:
last_poll_error = "feed had no <item>"
return
# Same as CNN: process the latest 50 only — keeps poll fast and avoids
# re-scanning the whole feed every 15s.
recent = items[:50]
entries = [e for e in (_to_cnn_shape(it) for it in recent) if e]
async with db_session_factory() as db:
try:
new_posts = []
for entry in entries:
try:
post = await _process_entry(entry, db)
if post:
new_posts.append(post)
except Exception as exc:
logger.error("trumpstruth: error on entry %s: %s",
entry.get("id"), exc)
if new_posts:
await db.commit()
for post in new_posts:
await manager.broadcast(_post_to_ws_payload(post))
logger.info("[trumpstruth] beat CNN — new post id=%d: %s",
post.id, post.text[:60])
try:
from app.services.bot_engine import process_post
await process_post(post, db)
except Exception as exc:
logger.error("process_post failed for post %d: %s",
post.id, exc)
last_successful_poll_at = datetime.now(timezone.utc)
last_poll_error = None
except Exception as exc:
logger.error("trumpstruth transaction error: %s", exc)
last_poll_error = f"transaction: {exc}"
await db.rollback()
+45 -11
View File
@@ -24,6 +24,11 @@ logger = logging.getLogger(__name__)
ARCHIVE_URL = "https://ix.cnn.io/data/truth-social/truth_archive.json" ARCHIVE_URL = "https://ix.cnn.io/data/truth-social/truth_archive.json"
# Liveness tracker — updated every successful poll (even when no new posts).
# Read by /api/health to detect a dead scraper. None = never ran yet.
last_successful_poll_at: Optional[datetime] = None
last_poll_error: Optional[str] = None
def _strip_html(text: str) -> str: def _strip_html(text: str) -> str:
text = re.sub(r"<[^>]+>", " ", text) text = re.sub(r"<[^>]+>", " ", text)
@@ -32,8 +37,17 @@ def _strip_html(text: str) -> str:
def _parse_dt(iso: str) -> datetime: def _parse_dt(iso: str) -> datetime:
"""Parse Truth Social's ISO timestamp into a naive-UTC datetime.
IMPORTANT: must convert to UTC *before* stripping tzinfo. Otherwise an
input like '2026-04-24T15:07:48-04:00' would be stored as naive
15:07:48 and later mis-read as UTC — a silent 4-hour shift.
"""
try: try:
return datetime.fromisoformat(iso.replace("Z", "+00:00")).replace(tzinfo=None) dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc)
return dt.replace(tzinfo=None)
except Exception: except Exception:
return datetime.now(timezone.utc).replace(tzinfo=None) return datetime.now(timezone.utc).replace(tzinfo=None)
@@ -69,12 +83,12 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
analysis = await analyze_post(text) analysis = await analyze_post(text)
asset = analysis["asset"] asset = analysis["asset"]
price_impact_m5 = price_impact_m15 = price_impact_m1h = price_at_post = None # Only capture the price AT post time. The m5/m15/m1h peaks are filled in
# asynchronously by price_impact_monitor as the windows elapse — avoids
# recording 0.00% because future candles don't exist yet at entry time.
price_at_post = None
if asset and analysis["relevant"]: if asset and analysis["relevant"]:
price_at_post = price_store.get_price_at(asset, published_at) price_at_post = price_store.get_price_at(asset, published_at)
price_impact_m5 = price_store.get_pct_change(asset, published_at, 5)
price_impact_m15 = price_store.get_pct_change(asset, published_at, 15)
price_impact_m1h = price_store.get_pct_change(asset, published_at, 60)
post = Post( post = Post(
external_id=external_id, external_id=external_id,
@@ -89,24 +103,38 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
analysis_version=analysis.get("analysis_version"), analysis_version=analysis.get("analysis_version"),
relevant=analysis["relevant"], relevant=analysis["relevant"],
price_impact_asset=asset if analysis["relevant"] else None, price_impact_asset=asset if analysis["relevant"] else None,
price_impact_m5=price_impact_m5, price_impact_m5=None, # filled by price_impact_monitor after 5 m
price_impact_m15=price_impact_m15, price_impact_m15=None, # filled by price_impact_monitor after 15 m
price_impact_m1h=price_impact_m1h, price_impact_m1h=None, # filled by price_impact_monitor after 1 h
price_at_post=price_at_post, price_at_post=price_at_post,
) )
db.add(post) db.add(post)
await db.flush() await db.flush()
# Register with the live peak tracker so it starts watching immediately.
if asset and analysis["relevant"] and price_at_post:
from app.services.price_impact_monitor import register_post
register_post(
post_id=post.id,
asset=asset,
signal=analysis.get("signal"),
entry_price=price_at_post,
published_at=published_at,
)
return post return post
def _post_to_ws_payload(post: Post) -> dict: def _post_to_ws_payload(post: Post) -> dict:
price_impact = None price_impact = None
if post.price_impact_asset and post.price_at_post is not None: if post.price_impact_asset and post.price_at_post is not None:
# At broadcast time all windows are open — values are null until
# price_impact_monitor fills them in. Frontend treats null as "pending".
price_impact = { price_impact = {
"asset": post.price_impact_asset, "asset": post.price_impact_asset,
"m5": post.price_impact_m5 or 0.0, "m5": post.price_impact_m5, # None = not yet measured
"m15": post.price_impact_m15 or 0.0, "m15": post.price_impact_m15,
"m1h": post.price_impact_m1h or 0.0, "m1h": post.price_impact_m1h,
"price_at_post": post.price_at_post, "price_at_post": post.price_at_post,
} }
return { return {
@@ -127,9 +155,11 @@ def _post_to_ws_payload(post: Post) -> dict:
async def poll_truth_social(db_session_factory) -> None: async def poll_truth_social(db_session_factory) -> None:
global last_successful_poll_at, last_poll_error
logger.info("Polling CNN Truth Social archive...") logger.info("Polling CNN Truth Social archive...")
entries = await _fetch_archive() entries = await _fetch_archive()
if not entries: if not entries:
last_poll_error = "fetch_archive returned empty"
return return
# Only process the latest 50 entries each poll (archive has 30k+ posts) # Only process the latest 50 entries each poll (archive has 30k+ posts)
@@ -159,8 +189,12 @@ async def poll_truth_social(db_session_factory) -> None:
logger.error("process_post failed for post %d: %s", post.id, exc) logger.error("process_post failed for post %d: %s", post.id, exc)
else: else:
logger.info("No new posts found.") logger.info("No new posts found.")
# Mark a successful poll cycle (separate from "found new posts").
last_successful_poll_at = datetime.now(timezone.utc)
last_poll_error = None
except Exception as exc: except Exception as exc:
logger.error("Transaction error: %s", exc) logger.error("Transaction error: %s", exc)
last_poll_error = f"transaction_error: {exc}"
await db.rollback() await db.rollback()
+1 -1
View File
@@ -207,7 +207,7 @@ async def analyze_post(text: str) -> dict:
if asset not in ("BTC", "ETH", None): if asset not in ("BTC", "ETH", None):
asset = None asset = None
reasoning = str(result.get("reasoning", ""))[:600] reasoning = str(result.get("reasoning", ""))[:1200]
return { return {
"relevant": relevant, "relevant": relevant,
+4
View File
@@ -45,6 +45,10 @@ async def _process_message(raw: str):
from app.services.tp_sl_monitor import on_price_tick from app.services.tp_sl_monitor import on_price_tick
on_price_tick(asset, candle["close"]) on_price_tick(asset, candle["close"])
# Price-impact peak tracker (updates rolling max for open post windows)
from app.services.price_impact_monitor import on_price_tick as pi_tick
pi_tick(asset, candle["high"], candle["low"], candle["close"])
# Broadcast live price tick # Broadcast live price tick
await manager.broadcast({ await manager.broadcast({
"type": "price", "type": "price",
+136 -11
View File
@@ -22,15 +22,38 @@ from app.services.price_store import price_store # noqa: F401 (used elsewhere)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Platform-wide thresholds (per-user values in Subscription override where applicable) # Platform-wide thresholds (per-user values in Subscription override where applicable)
GLOBAL_MIN_CONFIDENCE = 80 # hard floor — user min_confidence must be >= this # No global confidence floor — users pick their own 0100 threshold via settings.
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users) MAX_HOLD_SECONDS = 3600 # force-close after 1 hour (shared across users)
# Hyperliquid perp fees (mainnet, base tier).
# IOC orders always cross the book → taker fee both on open and close.
# Ref: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/fees
HL_TAKER_FEE_RATE = 0.00045 # 4.5 bps per side → 9 bps round-trip
# Per-trade locks prevent TP/SL and max-hold tasks from both calling close_and_finalize # Per-trade locks prevent TP/SL and max-hold tasks from both calling close_and_finalize
# simultaneously on the same trade. Lock is process-local — with the atomic # simultaneously on the same trade. Lock is process-local — with the atomic
# conditional UPDATE below, multi-process is still safe (losers become no-ops). # conditional UPDATE below, multi-process is still safe (losers become no-ops).
_close_locks: Dict[int, asyncio.Lock] = {} _close_locks: Dict[int, asyncio.Lock] = {}
# Strong references to background close tasks. Python's GC can collect
# unreferenced asyncio.Task objects before they finish — keeping them here
# prevents silent task cancellation. Entries are removed when tasks complete.
_background_tasks: set = set()
# Per-wallet locks for the open-position critical section.
# Prevents two concurrent signals from both passing the daily-budget check
# before either trade is written to DB (TOCTOU race).
_wallet_open_locks: Dict[str, asyncio.Lock] = {}
def _wallet_lock(wallet: str) -> asyncio.Lock:
lock = _wallet_open_locks.get(wallet)
if lock is None:
lock = asyncio.Lock()
_wallet_open_locks[wallet] = lock
return lock
def _lock_for(trade_id: int) -> asyncio.Lock: def _lock_for(trade_id: int) -> asyncio.Lock:
lock = _close_locks.get(trade_id) lock = _close_locks.get(trade_id)
@@ -47,14 +70,12 @@ async def process_post(post: Post, db: AsyncSession) -> None:
""" """
if not post.relevant: if not post.relevant:
return return
if (post.ai_confidence or 0) < GLOBAL_MIN_CONFIDENCE: if post.signal not in ('buy', 'short', 'sell'):
logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, GLOBAL_MIN_CONFIDENCE)
return
if post.signal not in ('buy', 'short'):
logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal) logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal)
return return
asset = post.price_impact_asset or 'BTC' asset = post.price_impact_asset or 'BTC'
# "sell" is treated as a short signal (bearish directional trade)
side = 'long' if post.signal == 'buy' else 'short' side = 'long' if post.signal == 'buy' else 'short'
logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence) logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence)
@@ -76,6 +97,9 @@ async def process_post(post: Post, db: AsyncSession) -> None:
take_profit_pct=s.take_profit_pct, take_profit_pct=s.take_profit_pct,
stop_loss_pct=s.stop_loss_pct, stop_loss_pct=s.stop_loss_pct,
min_confidence=s.min_confidence, min_confidence=s.min_confidence,
daily_budget_usd=s.daily_budget_usd,
active_from=s.active_from,
active_until=s.active_until,
) )
for s in subscribers for s in subscribers
] ]
@@ -100,17 +124,62 @@ async def _execute_for_subscriber(
if not sub["hl_api_key"]: if not sub["hl_api_key"]:
logger.warning("Subscriber %s has no HL API key, skipping", wallet) logger.warning("Subscriber %s has no HL API key, skipping", wallet)
return return
# Required-setup guard: the bot is only allowed to trade when the user
# has explicitly set take-profit, stop-loss, and a daily budget. Prevents
# accidental trading on partially-configured accounts.
missing = [k for k in ("take_profit_pct", "stop_loss_pct", "daily_budget_usd")
if sub.get(k) is None]
if missing:
logger.info("Sub %s skipped post %d: setup incomplete (missing %s)",
wallet, post_id, ", ".join(missing))
return
if post_confidence < sub["min_confidence"]: if post_confidence < sub["min_confidence"]:
logger.info("Sub %s filters out post %d: conf %d < user min %d", logger.info("Sub %s filters out post %d: conf %d < user min %d",
wallet, post_id, post_confidence, sub["min_confidence"]) wallet, post_id, post_confidence, sub["min_confidence"])
return return
# Active-window check. Both values stored as naive-UTC.
af = sub.get("active_from")
au = sub.get("active_until")
if af is not None and au is not None:
now_utc_naive = datetime.now(timezone.utc).replace(tzinfo=None)
if now_utc_naive < af or now_utc_naive >= au:
logger.info("Sub %s skipped post %d: outside active window [%s, %s)",
wallet, post_id, af, au)
return
try: try:
api_key = decrypt_api_key(sub["hl_api_key"]) api_key = decrypt_api_key(sub["hl_api_key"])
except Exception as exc: except Exception as exc:
logger.error("Cannot decrypt key for %s: %s", wallet, exc) logger.error("Cannot decrypt key for %s: %s", wallet, exc)
return return
# Per-wallet lock wraps BOTH the budget re-check and the open, so two
# concurrent signals can't both pass the daily-budget gate before either
# trade is written to DB (TOCTOU race under asyncio.gather).
async with _wallet_lock(wallet):
# Re-check daily budget INSIDE the lock so it's atomic with the open.
daily_cap = sub.get("daily_budget_usd")
if daily_cap is not None and daily_cap > 0:
start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
async with AsyncSessionLocal() as budget_db:
spent_result = await budget_db.execute(
select(BotTrade).where(
BotTrade.wallet_address == wallet,
BotTrade.opened_at >= start_of_day,
)
)
spent_trades = spent_result.scalars().all()
spent = sum(
(t.size_usd if t.size_usd is not None else sub["position_size_usd"])
for t in spent_trades
)
if spent + sub["position_size_usd"] > daily_cap:
logger.info("Sub %s daily budget reached: spent=%.2f + new=%.2f > cap=%.2f",
wallet, spent, sub["position_size_usd"], daily_cap)
return
# Each subscriber gets its own session — AsyncSession is NOT concurrency-safe. # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe.
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
try: try:
@@ -137,6 +206,8 @@ async def _execute_for_subscriber(
trigger_post_id=post_id, trigger_post_id=post_id,
opened_at=datetime.now(timezone.utc).replace(tzinfo=None), opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
hl_order_id=result.get('order_id'), hl_order_id=result.get('order_id'),
size_usd=sub["position_size_usd"],
leverage=sub["leverage"],
) )
db.add(trade) db.add(trade)
await db.commit() await db.commit()
@@ -158,9 +229,11 @@ async def _execute_for_subscriber(
stop_loss_pct=sub["stop_loss_pct"], stop_loss_pct=sub["stop_loss_pct"],
) )
asyncio.create_task(_close_after_hold( task = asyncio.create_task(_close_after_hold(
trade.id, api_key, sub["leverage"], asset, wallet trade.id, api_key, sub["leverage"], asset, wallet
)) ))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
except Exception as e: except Exception as e:
logger.error("Trade execution failed for %s: %s", wallet, e) logger.error("Trade execution failed for %s: %s", wallet, e)
@@ -200,27 +273,61 @@ async def close_and_finalize(
result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id)) result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id))
trade = result.scalar_one() trade = result.scalar_one()
# Prefer the historical snapshot stamped on the trade row;
# fall back to current Subscription or caller's leverage for
# legacy rows.
if trade.size_usd is not None:
size_usd = trade.size_usd
else:
sub_res = await db.execute( sub_res = await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet) select(Subscription).where(Subscription.wallet_address == wallet)
) )
sub = sub_res.scalar_one_or_none() sub = sub_res.scalar_one_or_none()
size_usd = sub.position_size_usd if sub else 20.0 size_usd = sub.position_size_usd if sub else 20.0
trade_leverage = trade.leverage if trade.leverage is not None else leverage
trader = HyperliquidTrader( trader = HyperliquidTrader(
api_private_key=api_key, api_private_key=api_key,
account_address=wallet, account_address=wallet,
leverage=leverage, leverage=trade_leverage,
mainnet=settings.hl_mainnet, mainnet=settings.hl_mainnet,
) )
close_result = await trader.close_position(asset) close_result = await trader.close_position(asset)
exit_price = close_result.get('fill_price', 0.0)
# Detect "position was already closed externally" before we even tried
if close_result.get("already_closed"):
logger.warning(
"Trade %d: no open %s position found on HL — "
"user likely closed it manually. Marking trade closed with no PnL.",
trade_id, asset,
)
trade.exit_price = None
trade.pnl_usd = None
trade.hold_seconds = int(
(datetime.now(timezone.utc) -
trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds()
)
await db.commit()
from app.services.tp_sl_monitor import unregister
unregister(trade_id)
_close_locks.pop(trade_id, None)
return
exit_price = close_result.get("fill_price")
if not exit_price:
raise ValueError(f"close_position returned no fill_price for trade {trade_id}")
now_aware = datetime.now(timezone.utc) now_aware = datetime.now(timezone.utc)
opened_aware = trade.opened_at.replace(tzinfo=timezone.utc) opened_aware = trade.opened_at.replace(tzinfo=timezone.utc)
hold_secs = int((now_aware - opened_aware).total_seconds()) hold_secs = int((now_aware - opened_aware).total_seconds())
pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0 pct = ((exit_price - trade.entry_price) / trade.entry_price) if trade.entry_price else 0.0
signed_pct = pct if trade.side == 'long' else -pct signed_pct = pct if trade.side == 'long' else -pct
pnl_usd = size_usd * signed_pct * leverage # size_usd is the NOTIONAL value — leverage affects margin, not PnL.
gross_pnl = size_usd * signed_pct
# Deduct round-trip taker fees (IOC crosses book on both open + close).
# Without this, displayed PnL overstates real returns by ~9 bps per trade.
fees_usd = size_usd * HL_TAKER_FEE_RATE * 2
pnl_usd = gross_pnl - fees_usd
trade.exit_price = exit_price trade.exit_price = exit_price
trade.pnl_usd = round(pnl_usd, 2) trade.pnl_usd = round(pnl_usd, 2)
@@ -233,11 +340,29 @@ async def close_and_finalize(
unregister(trade_id) unregister(trade_id)
_close_locks.pop(trade_id, None) _close_locks.pop(trade_id, None)
logger.info("Closed %s %s for %s @ %.2f PnL=%.2f (reason=%s)", logger.info(
trade.side, asset, wallet, exit_price, pnl_usd, reason) "Closed %s %s for %s @ %.2f gross=%.2f fees=%.2f net=%.2f (reason=%s)",
trade.side, asset, wallet, exit_price,
gross_pnl, fees_usd, pnl_usd, reason,
)
except Exception as e: except Exception as e:
logger.error("Failed to close trade %d: %s", trade_id, e) logger.error("Failed to close trade %d: %s", trade_id, e)
# Rollback closed_at so recovery can retry this trade on next restart.
# Without this, the trade is "closed" in DB but position still open on HL.
try:
async with AsyncSessionLocal() as rb_db:
await rb_db.execute(
update(BotTrade)
.where(BotTrade.id == trade_id)
.values(closed_at=None)
)
await rb_db.commit()
logger.info("Rolled back closed_at for trade %d — will retry on recovery", trade_id)
except Exception as rb_exc:
logger.error("Failed to rollback closed_at for trade %d: %s", trade_id, rb_exc)
finally:
_close_locks.pop(trade_id, None)
async def _close_after_hold( async def _close_after_hold(
+44 -16
View File
@@ -111,7 +111,7 @@ class HyperliquidTrader:
return positions return positions
except Exception as exc: except Exception as exc:
logger.error("get_open_positions error: %s", exc) logger.error("get_open_positions error: %s", exc)
return [] raise
async def set_leverage(self, coin: str) -> None: async def set_leverage(self, coin: str) -> None:
"""Set isolated leverage for the given coin.""" """Set isolated leverage for the given coin."""
@@ -180,14 +180,30 @@ class HyperliquidTrader:
) )
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
filled = statuses[0] if statuses else {} status = statuses[0] if statuses else {}
fill_price = float(
(filled.get("filled") or {}).get("avgPx", mid) or mid
)
order_id = str((filled.get("resting") or {}).get("oid", ""))
logger.info("Opened %s %s @ %.2f (order_id=%s)", side, coin, fill_price, order_id) # "error" key present → HL rejected the order entirely
return {"order_id": order_id, "fill_price": fill_price, "size_coins": size_coins} if "error" in status:
raise ValueError(f"HL order rejected: {status['error']}")
filled_info = status.get("filled") or {}
total_sz = float(filled_info.get("totalSz", 0) or 0)
if total_sz <= 0:
# IOC returned with zero fill — no position was opened
raise ValueError(
f"IOC order for {coin} returned 0 fill "
f"(status={status}). No position opened."
)
fill_price = float(filled_info.get("avgPx", mid) or mid)
# oid lives under "filled" for IOC; "resting" is fallback for unexpected GTC
oid_src = filled_info or status.get("resting") or {}
order_id = str(oid_src.get("oid", "")) if oid_src else ""
logger.info("Opened %s %s @ %.2f size=%.5f (order_id=%s)",
side, coin, fill_price, total_sz, order_id)
return {"order_id": order_id, "fill_price": fill_price, "size_coins": total_sz}
async def close_position(self, asset: str) -> dict: async def close_position(self, asset: str) -> dict:
""" """
@@ -201,8 +217,9 @@ class HyperliquidTrader:
positions = await self.get_open_positions() positions = await self.get_open_positions()
target = next((p for p in positions if p.get("coin") == coin), None) target = next((p for p in positions if p.get("coin") == coin), None)
if target is None: if target is None:
logger.warning("No open %s position to close", coin) logger.warning("No open %s position found on HL — already closed externally?", coin)
return {"fill_price": 0.0} # Return sentinel so callers can detect "nothing to close" vs "closed @ price"
return {"fill_price": None, "already_closed": True}
szi = float(target["szi"]) szi = float(target["szi"])
is_buy = szi < 0 # closing a short → buy back is_buy = szi < 0 # closing a short → buy back
@@ -235,10 +252,21 @@ class HyperliquidTrader:
) )
statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) statuses = result.get("response", {}).get("data", {}).get("statuses", [{}])
filled = statuses[0] if statuses else {} status = statuses[0] if statuses else {}
fill_price = float( filled_info = status.get("filled") or {}
(filled.get("filled") or {}).get("avgPx", mid) or mid total_sz = float(filled_info.get("totalSz", 0) or 0)
)
logger.info("Closed %s position @ %.2f", coin, fill_price) if total_sz <= 0:
return {"fill_price": fill_price} # IOC close got 0 fill — position may have already been closed externally.
# Re-check to be sure before giving up.
positions_after = await self.get_open_positions()
still_open = next((p for p in positions_after if p.get("coin") == coin), None)
if still_open is None:
logger.info("Close IOC got 0 fill but position is gone — treated as closed", )
return {"fill_price": mid, "already_closed": False}
logger.error("Close IOC got 0 fill and position still open for %s", coin)
raise ValueError(f"Failed to close {coin} position: IOC returned 0 fill")
fill_price = float(filled_info.get("avgPx", mid) or mid)
logger.info("Closed %s position @ %.2f (size=%.5f)", coin, fill_price, total_sz)
return {"fill_price": fill_price, "already_closed": False}
+204
View File
@@ -0,0 +1,204 @@
"""
Rolling price-impact tracker for Trump posts.
For each newly-saved relevant post, we track the peak favorable move
(max high for BUY signals, max low for SHORT signals) in three windows:
5 m, 15 m, and 1 h.
Rules:
- While the window is OPEN → live rolling peak, updated on every price tick
- When the window CLOSES → final peak is written to DB, window is sealed
- When all three windows close → post is unregistered to free memory
The result is that:
• A brand-new post immediately shows the running peak since publication.
• A post 20 minutes old shows final m5, final m15, and live 1h peak.
• A post 2 hours old shows final m5 / m15 / m1h from DB.
Integration points:
binance.py → call on_price_tick(asset, high, low, close) each candle
truth_social.py → call register_post(...) after flush, before commit
posts.py → call get_live_impact(post_id) to overlay live values
"""
import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, Optional
logger = logging.getLogger(__name__)
# Window durations in seconds
WINDOWS = {"m5": 5 * 60, "m15": 15 * 60, "m1h": 60 * 60}
@dataclass
class TrackedPost:
post_id: int
asset: str
signal: Optional[str] # "buy" | "short" | "sell" | "hold" | None
entry_price: float # price at post time
published_at: datetime # naive UTC
# Running peaks — signed % relative to entry_price
# For BUY: positive = price went up (we want max)
# For SHORT: positive = price went down (we want max of -pct)
peak_m5: Optional[float] = None
peak_m15: Optional[float] = None
peak_m1h: Optional[float] = None
# True once the window has expired and the value is finalised in DB
done_m5: bool = False
done_m15: bool = False
done_m1h: bool = False
# post_id → TrackedPost
_tracked: Dict[int, TrackedPost] = {}
# Strong refs to DB-write tasks so GC doesn't collect them mid-flight
_background_tasks: set = set()
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
def register_post(
post_id: int,
asset: str,
signal: Optional[str],
entry_price: float,
published_at: datetime,
) -> None:
"""Call immediately after a relevant post is flushed to DB."""
if entry_price <= 0:
return
if not asset:
return
_tracked[post_id] = TrackedPost(
post_id=post_id,
asset=asset.upper(),
signal=signal,
entry_price=entry_price,
published_at=published_at.replace(tzinfo=None), # store as naive UTC
)
logger.info("PriceImpact: tracking post %d (%s %s @ %.2f)",
post_id, signal, asset, entry_price)
def unregister(post_id: int) -> None:
_tracked.pop(post_id, None)
def get_live_impact(post_id: int) -> Optional[dict]:
"""
Return the current live peaks for a post if it is still being tracked.
Returns None if the post has never been registered or all windows are done.
The dict keys match the Post model columns:
price_impact_m5, price_impact_m15, price_impact_m1h
Only keys with open (not-yet-sealed) windows are included — callers
should overlay these on top of DB values.
"""
tp = _tracked.get(post_id)
if tp is None:
return None
out: dict = {}
if not tp.done_m5:
out["price_impact_m5"] = tp.peak_m5
if not tp.done_m15:
out["price_impact_m15"] = tp.peak_m15
if not tp.done_m1h:
out["price_impact_m1h"] = tp.peak_m1h
return out if out else None
def on_price_tick(asset: str, high: float, low: float, close: float) -> None:
"""
Called by binance.py on every 1-minute candle close.
Updates running peaks and fires DB-write tasks for expired windows.
"""
asset = asset.upper()
if not _tracked:
return
now_naive = datetime.now(timezone.utc).replace(tzinfo=None)
for post_id, tp in list(_tracked.items()):
if tp.asset != asset:
continue
age_s = (now_naive - tp.published_at).total_seconds()
# Pick the extreme price for this signal direction
# BUY → we care about how high price went (use candle high)
# SHORT/SELL → we care about how low price went (use candle low)
is_long = tp.signal in ("buy",)
extreme = high if is_long else low
# signed % gain in the signal's direction
if tp.entry_price > 0:
raw_pct = (extreme - tp.entry_price) / tp.entry_price * 100
signed_pct = raw_pct if is_long else -raw_pct
else:
signed_pct = None
# Update each open window
for win, duration in WINDOWS.items():
done_attr = f"done_{win}"
peak_attr = f"peak_{win}"
if getattr(tp, done_attr):
continue # already sealed
if signed_pct is not None:
cur = getattr(tp, peak_attr)
if cur is None or signed_pct > cur:
setattr(tp, peak_attr, round(signed_pct, 4))
# Has the window expired?
if age_s >= duration:
setattr(tp, done_attr, True)
final_peak = getattr(tp, peak_attr)
t = asyncio.create_task(_write_window_to_db(post_id, win, final_peak))
_background_tasks.add(t)
t.add_done_callback(_background_tasks.discard)
logger.debug("PriceImpact: post %d window %s closed → peak=%.4f%%",
post_id, win, final_peak or 0)
# All windows done → free memory
if tp.done_m5 and tp.done_m15 and tp.done_m1h:
unregister(post_id)
logger.info("PriceImpact: post %d fully tracked, unregistered", post_id)
# ─────────────────────────────────────────────────────────────────────────────
# DB write helpers
# ─────────────────────────────────────────────────────────────────────────────
_WINDOW_COLUMN = {
"m5": "price_impact_m5",
"m15": "price_impact_m15",
"m1h": "price_impact_m1h",
}
async def _write_window_to_db(post_id: int, window: str, value: Optional[float]) -> None:
"""Write the final peak for a single window to the DB."""
from sqlalchemy import update
from app.database import AsyncSessionLocal
from app.models import Post
col = _WINDOW_COLUMN[window]
try:
async with AsyncSessionLocal() as db:
await db.execute(
update(Post)
.where(Post.id == post_id)
.values({col: value})
)
await db.commit()
logger.info("PriceImpact: wrote post %d %s=%.4f%% to DB",
post_id, window, value or 0)
except Exception as exc:
logger.error("PriceImpact: failed to write post %d %s: %s", post_id, window, exc)
+16 -6
View File
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
async def rehydrate_open_trades() -> None: async def rehydrate_open_trades() -> None:
# Imported locally to avoid circular imports at module load # Imported locally to avoid circular imports at module load
from app.services.bot_engine import MAX_HOLD_SECONDS, _close_after_hold, close_and_finalize from app.services.bot_engine import MAX_HOLD_SECONDS, close_and_finalize
from app.services.crypto import decrypt_api_key from app.services.crypto import decrypt_api_key
from app.services.tp_sl_monitor import register_trade from app.services.tp_sl_monitor import register_trade
@@ -49,12 +49,16 @@ async def rehydrate_open_trades() -> None:
logger.error("Cannot decrypt key for trade %d: %s", t.id, exc) logger.error("Cannot decrypt key for trade %d: %s", t.id, exc)
continue continue
# Use the leverage snapshot from the trade row (stamped at open time).
# Fall back to current Subscription only for legacy rows (pre-migration 005).
trade_leverage = t.leverage if t.leverage is not None else sub.leverage
# Re-register TP/SL watcher # Re-register TP/SL watcher
register_trade( register_trade(
trade_id=t.id, trade_id=t.id,
wallet=t.wallet_address, wallet=t.wallet_address,
api_key=api_key, api_key=api_key,
leverage=sub.leverage, leverage=trade_leverage,
asset=t.asset, asset=t.asset,
side=t.side, side=t.side,
entry_price=t.entry_price, entry_price=t.entry_price,
@@ -67,23 +71,29 @@ async def rehydrate_open_trades() -> None:
elapsed = (now - opened_aware).total_seconds() elapsed = (now - opened_aware).total_seconds()
remaining = MAX_HOLD_SECONDS - elapsed remaining = MAX_HOLD_SECONDS - elapsed
from app.services.bot_engine import _background_tasks
if remaining <= 0: if remaining <= 0:
logger.info("Trade %d past max-hold on startup — closing now", t.id) logger.info("Trade %d past max-hold on startup — closing now", t.id)
asyncio.create_task( task = asyncio.create_task(
close_and_finalize( close_and_finalize(
trade_id=t.id, api_key=api_key, leverage=sub.leverage, trade_id=t.id, api_key=api_key, leverage=trade_leverage,
asset=t.asset, wallet=t.wallet_address, reason="max_hold_recovery", asset=t.asset, wallet=t.wallet_address, reason="max_hold_recovery",
) )
) )
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
else: else:
async def _delayed_close(trade_id=t.id, key=api_key, lev=sub.leverage, async def _delayed_close(trade_id=t.id, key=api_key, lev=trade_leverage,
asset=t.asset, wallet=t.wallet_address, delay=remaining): asset=t.asset, wallet=t.wallet_address, delay=remaining):
await asyncio.sleep(delay) await asyncio.sleep(delay)
await close_and_finalize( await close_and_finalize(
trade_id=trade_id, api_key=key, leverage=lev, trade_id=trade_id, api_key=key, leverage=lev,
asset=asset, wallet=wallet, reason="max_hold", asset=asset, wallet=wallet, reason="max_hold",
) )
asyncio.create_task(_delayed_close()) task = asyncio.create_task(_delayed_close())
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
await db.commit() await db.commit()
logger.info("Rehydrated %d open trades.", len(open_trades)) logger.info("Rehydrated %d open trades.", len(open_trades))
+6 -1
View File
@@ -33,6 +33,9 @@ class WatchedTrade:
# trade_id -> WatchedTrade # trade_id -> WatchedTrade
_watched: Dict[int, WatchedTrade] = {} _watched: Dict[int, WatchedTrade] = {}
# Strong references to fire-close tasks to prevent GC before completion
_background_tasks: set = set()
def register_trade( def register_trade(
trade_id: int, trade_id: int,
@@ -79,7 +82,9 @@ def on_price_tick(asset: str, price: float) -> None:
for wt, reason in triggered: for wt, reason in triggered:
unregister(wt.trade_id) unregister(wt.trade_id)
asyncio.create_task(_fire_close(wt, reason)) task = asyncio.create_task(_fire_close(wt, reason))
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
async def _fire_close(wt: WatchedTrade, reason: str) -> None: async def _fire_close(wt: WatchedTrade, reason: str) -> None:
+155
View File
@@ -0,0 +1,155 @@
# Backend deployment + uptime monitoring
This folder collects the moving pieces that turn the backend from "runs on
my laptop" into "runs 24/7 with auto-restart and external alerting".
## What the bot needs to be reliable
1. **Auto-restart on crash** — handled by systemd (`Restart=always`) or
Docker Compose (`restart: unless-stopped`). Pick one.
2. **Health endpoints** — exposed by `app/main.py`:
- `GET /api/health` (shallow): 200 as long as the FastAPI process is alive.
Used by Docker's internal `HEALTHCHECK`.
- `GET /api/health/deep`: 200 only if DB ping works AND at least one
Truth Social scraper has polled in the last 90s. Used by external
uptime monitors.
3. **Dual-source scraper**`truth_social.py` (CNN archive) +
`trumpstruth.py` (RSS). Both insert into `posts` with the same
`external_id` hash, so duplicates are dropped. Whoever sees the post
first wins.
4. **External uptime monitor** — pings the deep health endpoint from
outside the box. If we lose internet or the box is on fire, we get
alerted. Recommended: UptimeRobot (free tier covers this).
---
## Option A — Docker Compose (recommended)
`docker-compose.yml` is already configured with:
```yaml
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
```
Deploy steps on a fresh server:
```bash
git clone <repo>
cd backend
cp .env.example .env
# Fill in: SERVICE_USER_POSTGRES, SERVICE_PASSWORD_POSTGRES, FRONTEND_URL,
# ENCRYPTION_KEY, AI_API_KEY, HL_MAINNET=true
docker compose up -d
docker compose logs -f api # tail logs
```
To verify auto-restart works:
```bash
docker compose kill api # simulate crash
sleep 12 # wait for compose to relaunch
curl http://localhost:8000/api/health # should return {"status":"ok"}
```
---
## Option B — systemd (bare-metal, no Docker)
Use `trumpsignal-api.service` from this folder.
```bash
# 1. Code lives at /opt/trumpsignal/backend
sudo useradd -m -d /opt/trumpsignal trumpsignal
sudo -u trumpsignal git clone <repo> /opt/trumpsignal/backend
cd /opt/trumpsignal/backend
sudo -u trumpsignal python3.11 -m venv venv
sudo -u trumpsignal venv/bin/pip install -r requirements.txt
# 2. Configure
sudo -u trumpsignal cp .env.example .env
sudo -u trumpsignal nano .env # fill in secrets
# 3. Install + start the service
sudo cp deploy/trumpsignal-api.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now trumpsignal-api
# 4. Tail logs
sudo journalctl -u trumpsignal-api -f
```
Test auto-restart:
```bash
sudo systemctl kill --signal=SIGKILL trumpsignal-api
sleep 12
curl http://localhost:8000/api/health # should return {"status":"ok"}
```
---
## UptimeRobot setup
1. Sign up at https://uptimerobot.com (free tier = 50 monitors, 5-min interval).
2. Add new monitor:
- **Type:** HTTP(s)
- **URL:** `https://api.trump-signal.bitnews.day/api/health/deep`
(replace with your real domain)
- **Monitoring interval:** 5 min
- **Alert contacts:** add your email and/or Telegram
3. UptimeRobot will alert you when the deep healthcheck returns 503 — i.e.
when both scrapers have been silent for >90s, OR when the DB is
unreachable.
### Why deep, not shallow
The shallow `/api/health` returns 200 even if the scraper has died but the
FastAPI process is still serving HTTP. A monitor pointed at the shallow
endpoint would happily report "all green" while the bot silently misses
posts. Always point external monitors at `/api/health/deep`.
---
## Reading the deep healthcheck
```json
{
"status": "ok",
"now": "2026-04-25T07:35:01.123456+00:00",
"db_ok": true,
"db_error": null,
"scrapers": [
{ "name": "cnn", "last_poll": "2026-04-25T07:34:58Z", "age_sec": 3, "last_error": null },
{ "name": "trumpstruth", "last_poll": "2026-04-25T07:34:52Z", "age_sec": 9, "last_error": null }
],
"freshest_age_sec": 3,
"problems": []
}
```
`status: "degraded"` + HTTP 503 means at least one item in `problems` is
non-empty. The most common failure modes:
- `db: <error>` — Postgres/SQLite can't be reached. Check `DATABASE_URL`.
- `scrapers: all stale (freshest=Ns)` — both upstream feeds silent. Check
network egress, then check if both `ix.cnn.io` and `trumpstruth.org` are
responding from the box: `curl -sI https://ix.cnn.io/data/truth-social/truth_archive.json`
---
## Burst-protection note (systemd)
The unit file caps restarts at 5 within 60s, so a hard misconfiguration
(missing env var, etc.) stops the boot loop instead of thrashing forever.
After fixing the underlying issue:
```bash
sudo systemctl reset-failed trumpsignal-api
sudo systemctl start trumpsignal-api
```
+37
View File
@@ -0,0 +1,37 @@
[Unit]
Description=TrumpSignal API + Truth Social poller
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=trumpsignal
Group=trumpsignal
WorkingDirectory=/opt/trumpsignal/backend
EnvironmentFile=/opt/trumpsignal/backend/.env
ExecStart=/opt/trumpsignal/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
# Auto-restart on any exit (crash, OOM, segfault, manual kill).
# RestartSec=10 = wait 10s between restart attempts so we don't hammer a broken state.
Restart=always
RestartSec=10
# Burst protection: if we crash > 5 times in 60s, give up (likely a real config bug,
# not transient). Reset by `systemctl reset-failed trumpsignal-api`.
StartLimitIntervalSec=60
StartLimitBurst=5
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/trumpsignal/backend
PrivateTmp=yes
# Logging — journald captures stdout/stderr; view with `journalctl -u trumpsignal-api -f`
StandardOutput=journal
StandardError=journal
SyslogIdentifier=trumpsignal-api
[Install]
WantedBy=multi-user.target
BIN
View File
Binary file not shown.