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:
@@ -24,6 +24,11 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
text = re.sub(r"<[^>]+>", " ", text)
|
||||
@@ -32,8 +37,17 @@ def _strip_html(text: str) -> str:
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
|
||||
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"]:
|
||||
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(
|
||||
external_id=external_id,
|
||||
@@ -89,24 +103,38 @@ async def _process_entry(entry: dict, db: AsyncSession) -> Optional[Post]:
|
||||
analysis_version=analysis.get("analysis_version"),
|
||||
relevant=analysis["relevant"],
|
||||
price_impact_asset=asset if analysis["relevant"] else None,
|
||||
price_impact_m5=price_impact_m5,
|
||||
price_impact_m15=price_impact_m15,
|
||||
price_impact_m1h=price_impact_m1h,
|
||||
price_impact_m5=None, # filled by price_impact_monitor after 5 m
|
||||
price_impact_m15=None, # filled by price_impact_monitor after 15 m
|
||||
price_impact_m1h=None, # filled by price_impact_monitor after 1 h
|
||||
price_at_post=price_at_post,
|
||||
)
|
||||
db.add(post)
|
||||
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
|
||||
|
||||
|
||||
def _post_to_ws_payload(post: Post) -> dict:
|
||||
price_impact = 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 = {
|
||||
"asset": post.price_impact_asset,
|
||||
"m5": post.price_impact_m5 or 0.0,
|
||||
"m15": post.price_impact_m15 or 0.0,
|
||||
"m1h": post.price_impact_m1h or 0.0,
|
||||
"m5": post.price_impact_m5, # None = not yet measured
|
||||
"m15": post.price_impact_m15,
|
||||
"m1h": post.price_impact_m1h,
|
||||
"price_at_post": post.price_at_post,
|
||||
}
|
||||
return {
|
||||
@@ -127,9 +155,11 @@ def _post_to_ws_payload(post: Post) -> dict:
|
||||
|
||||
|
||||
async def poll_truth_social(db_session_factory) -> None:
|
||||
global last_successful_poll_at, last_poll_error
|
||||
logger.info("Polling CNN Truth Social archive...")
|
||||
entries = await _fetch_archive()
|
||||
if not entries:
|
||||
last_poll_error = "fetch_archive returned empty"
|
||||
return
|
||||
|
||||
# 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)
|
||||
else:
|
||||
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:
|
||||
logger.error("Transaction error: %s", exc)
|
||||
last_poll_error = f"transaction_error: {exc}"
|
||||
await db.rollback()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user