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:
@@ -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)
|
||||
Reference in New Issue
Block a user