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
+45 -11
View File
@@ -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()