""" 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)