From 4ffcb442fee3754898f8f86c60c16b888760640d Mon Sep 17 00:00:00 2001 From: k Date: Sat, 25 Apr 2026 16:04:49 +0800 Subject: [PATCH] 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 --- alembic/versions/003_add_daily_budget.py | 25 +++ alembic/versions/004_add_active_window.py | 27 +++ alembic/versions/005_bottrade_snapshot.py | 27 +++ app/api/posts.py | 20 +- app/api/user.py | 57 ++++- app/main.py | 95 ++++++++- app/models.py | 10 + app/schemas.py | 14 +- app/scrapers/trumpstruth.py | 155 ++++++++++++++ app/scrapers/truth_social.py | 56 ++++- app/services/analysis.py | 2 +- app/services/binance.py | 4 + app/services/bot_engine.py | 247 ++++++++++++++++------ app/services/hyperliquid.py | 60 ++++-- app/services/price_impact_monitor.py | 204 ++++++++++++++++++ app/services/recovery.py | 22 +- app/services/tp_sl_monitor.py | 7 +- deploy/README.md | 155 ++++++++++++++ deploy/trumpsignal-api.service | 37 ++++ trumpsignal.db | Bin 622592 -> 716800 bytes 20 files changed, 1110 insertions(+), 114 deletions(-) create mode 100644 alembic/versions/003_add_daily_budget.py create mode 100644 alembic/versions/004_add_active_window.py create mode 100644 alembic/versions/005_bottrade_snapshot.py create mode 100644 app/scrapers/trumpstruth.py create mode 100644 app/services/price_impact_monitor.py create mode 100644 deploy/README.md create mode 100644 deploy/trumpsignal-api.service diff --git a/alembic/versions/003_add_daily_budget.py b/alembic/versions/003_add_daily_budget.py new file mode 100644 index 0000000..19e4006 --- /dev/null +++ b/alembic/versions/003_add_daily_budget.py @@ -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") diff --git a/alembic/versions/004_add_active_window.py b/alembic/versions/004_add_active_window.py new file mode 100644 index 0000000..fcfc15a --- /dev/null +++ b/alembic/versions/004_add_active_window.py @@ -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") diff --git a/alembic/versions/005_bottrade_snapshot.py b/alembic/versions/005_bottrade_snapshot.py new file mode 100644 index 0000000..85b9b63 --- /dev/null +++ b/alembic/versions/005_bottrade_snapshot.py @@ -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") diff --git a/app/api/posts.py b/app/api/posts.py index f438841..b6549a9 100644 --- a/app/api/posts.py +++ b/app/api/posts.py @@ -26,15 +26,23 @@ def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[ def _post_to_schema(post: Post) -> TrumpPost: price_impact: Optional[PriceImpact] = 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( 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=m5, + m15=m15, + m1h=m1h, price_at_post=post.price_at_post, - correct_m5=_direction_correct(post.signal, post.price_impact_m5), - correct_m15=_direction_correct(post.signal, post.price_impact_m15), - correct_m1h=_direction_correct(post.signal, post.price_impact_m1h), + correct_m5=_direction_correct(post.signal, m5), + correct_m15=_direction_correct(post.signal, m15), + correct_m1h=_direction_correct(post.signal, m1h), ) return TrumpPost( id=post.id, diff --git a/app/api/user.py b/app/api/user.py index 30fc300..9c13962 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -1,6 +1,6 @@ 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.ext.asyncio import AsyncSession @@ -150,6 +150,9 @@ async def get_user( take_profit_pct=sub.take_profit_pct, stop_loss_pct=sub.stop_loss_pct, 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) async def set_user_settings( wallet: str, - body: SetSettingsRequest, + request: Request, db: AsyncSession = Depends(get_db), ): 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(): raise HTTPException(400, "Wallet mismatch") @@ -169,19 +182,46 @@ async def set_user_settings( raise HTTPException(422, "leverage must be 1–50") if not (5 <= s.position_size_usd <= 10000): 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): - raise HTTPException(422, "take_profit_pct must be 0.1–50") - if s.stop_loss_pct is not None and not (0.1 <= s.stop_loss_pct <= 50): - raise HTTPException(422, "stop_loss_pct must be 0.1–50") + # Take-profit, stop-loss and daily budget are now MANDATORY — the bot won't + # run without them, so we reject null here and force the client to supply values. + if s.take_profit_pct is None or not (0.1 <= s.take_profit_pct <= 50): + raise HTTPException(422, "take_profit_pct is required (0.1–50)") + 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.1–50)") if not (0 <= s.min_confidence <= 100): raise HTTPException(422, "min_confidence must be 0–100") + 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( action=ACTION_SET_SETTINGS, wallet=wallet, timestamp_ms=body.timestamp, signature=body.signature, - body=s.model_dump(), + body=raw_settings, ) 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.stop_loss_pct = s.stop_loss_pct 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() logger.info("Settings updated for %s: %s", wallet, s.model_dump()) return s diff --git a/app/main.py b/app/main.py index 8347735..aa638ff 100644 --- a/app/main.py +++ b/app/main.py @@ -10,6 +10,7 @@ from app.config import settings from app.database import AsyncSessionLocal, engine from app.models import Base 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.ws.manager import manager @@ -64,9 +65,24 @@ async def lifespan(app: FastAPI): max_instances=1, 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() 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 @@ -117,8 +133,85 @@ app.include_router(user_router, prefix="/api") @app.get("/api/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"} + +@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": from app.api.dev import router as dev_router app.include_router(dev_router, prefix="/api") diff --git a/app/models.py b/app/models.py index 715e67f..03cd18e 100644 --- a/app/models.py +++ b/app/models.py @@ -90,6 +90,11 @@ class BotTrade(Base): opened_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, 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") @@ -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% 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) + 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) diff --git a/app/schemas.py b/app/schemas.py index 7055fb4..d508931 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -5,11 +5,13 @@ from pydantic import BaseModel class PriceImpact(BaseModel): asset: str - m5: float - m15: float - m1h: float + # None = window still open (live rolling peak) or no price data yet. + # Float = sealed peak move (%) in the signal direction once window closed. + m5: Optional[float] = None + m15: Optional[float] = None + m1h: Optional[float] = None 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_m15: Optional[bool] = None correct_m1h: Optional[bool] = None @@ -101,6 +103,10 @@ class UserSettings(BaseModel): take_profit_pct: Optional[float] = None stop_loss_pct: Optional[float] = None 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): diff --git a/app/scrapers/trumpstruth.py b/app/scrapers/trumpstruth.py new file mode 100644 index 0000000..13dde6c --- /dev/null +++ b/app/scrapers/trumpstruth.py @@ -0,0 +1,155 @@ +""" +Trump Truth Social scraper — trumpstruth.org RSS fallback. + +Why a second scraper? + CNN's archive (the primary source) sometimes lags 5–10 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 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 " + 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() diff --git a/app/scrapers/truth_social.py b/app/scrapers/truth_social.py index 380dfed..a8c4cc5 100644 --- a/app/scrapers/truth_social.py +++ b/app/scrapers/truth_social.py @@ -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() diff --git a/app/services/analysis.py b/app/services/analysis.py index 211123e..22a1c6e 100644 --- a/app/services/analysis.py +++ b/app/services/analysis.py @@ -207,7 +207,7 @@ async def analyze_post(text: str) -> dict: if asset not in ("BTC", "ETH", None): asset = None - reasoning = str(result.get("reasoning", ""))[:600] + reasoning = str(result.get("reasoning", ""))[:1200] return { "relevant": relevant, diff --git a/app/services/binance.py b/app/services/binance.py index dd9d209..e204faa 100644 --- a/app/services/binance.py +++ b/app/services/binance.py @@ -45,6 +45,10 @@ async def _process_message(raw: str): from app.services.tp_sl_monitor import on_price_tick 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 await manager.broadcast({ "type": "price", diff --git a/app/services/bot_engine.py b/app/services/bot_engine.py index 3076d1c..029bf85 100644 --- a/app/services/bot_engine.py +++ b/app/services/bot_engine.py @@ -22,15 +22,38 @@ from app.services.price_store import price_store # noqa: F401 (used elsewhere) logger = logging.getLogger(__name__) # 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 0–100 threshold via settings. 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 # simultaneously on the same trade. Lock is process-local — with the atomic # conditional UPDATE below, multi-process is still safe (losers become no-ops). _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: lock = _close_locks.get(trade_id) @@ -47,14 +70,12 @@ async def process_post(post: Post, db: AsyncSession) -> None: """ if not post.relevant: return - if (post.ai_confidence or 0) < GLOBAL_MIN_CONFIDENCE: - logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, GLOBAL_MIN_CONFIDENCE) - return - if post.signal not in ('buy', 'short'): + if post.signal not in ('buy', 'short', 'sell'): logger.info("Post %d skipped: signal=%s is not actionable", post.id, post.signal) return 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' 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, stop_loss_pct=s.stop_loss_pct, 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 ] @@ -100,70 +124,119 @@ async def _execute_for_subscriber( if not sub["hl_api_key"]: logger.warning("Subscriber %s has no HL API key, skipping", wallet) 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"]: logger.info("Sub %s filters out post %d: conf %d < user min %d", wallet, post_id, post_confidence, sub["min_confidence"]) 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: api_key = decrypt_api_key(sub["hl_api_key"]) except Exception as exc: logger.error("Cannot decrypt key for %s: %s", wallet, exc) return - # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe. - async with AsyncSessionLocal() as db: - try: - trader = HyperliquidTrader( - api_private_key=api_key, - account_address=wallet, - leverage=sub["leverage"], - mainnet=settings.hl_mainnet, - ) - - open_positions = await trader.get_open_positions() - if any(p.get('coin') == asset for p in open_positions): - logger.info("Subscriber %s already has open %s position, skipping", wallet, asset) + # 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 - result = await trader.open_position(asset, side, sub["position_size_usd"]) - entry_price = result.get('fill_price', 0.0) + # Each subscriber gets its own session — AsyncSession is NOT concurrency-safe. + async with AsyncSessionLocal() as db: + try: + trader = HyperliquidTrader( + api_private_key=api_key, + account_address=wallet, + leverage=sub["leverage"], + mainnet=settings.hl_mainnet, + ) - trade = BotTrade( - asset=asset, - side=side, - entry_price=entry_price, - wallet_address=wallet, - trigger_post_id=post_id, - opened_at=datetime.now(timezone.utc).replace(tzinfo=None), - hl_order_id=result.get('order_id'), - ) - db.add(trade) - await db.commit() - await db.refresh(trade) + open_positions = await trader.get_open_positions() + if any(p.get('coin') == asset for p in open_positions): + logger.info("Subscriber %s already has open %s position, skipping", wallet, asset) + return - logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", - side, asset, wallet, entry_price, trade.id) + result = await trader.open_position(asset, side, sub["position_size_usd"]) + entry_price = result.get('fill_price', 0.0) - from app.services.tp_sl_monitor import register_trade - register_trade( - trade_id=trade.id, - wallet=wallet, - api_key=api_key, - leverage=sub["leverage"], - asset=asset, - side=side, - entry_price=entry_price, - take_profit_pct=sub["take_profit_pct"], - stop_loss_pct=sub["stop_loss_pct"], - ) + trade = BotTrade( + asset=asset, + side=side, + entry_price=entry_price, + wallet_address=wallet, + trigger_post_id=post_id, + opened_at=datetime.now(timezone.utc).replace(tzinfo=None), + hl_order_id=result.get('order_id'), + size_usd=sub["position_size_usd"], + leverage=sub["leverage"], + ) + db.add(trade) + await db.commit() + await db.refresh(trade) - asyncio.create_task(_close_after_hold( - trade.id, api_key, sub["leverage"], asset, wallet - )) + logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", + side, asset, wallet, entry_price, trade.id) - except Exception as e: - logger.error("Trade execution failed for %s: %s", wallet, e) + from app.services.tp_sl_monitor import register_trade + register_trade( + trade_id=trade.id, + wallet=wallet, + api_key=api_key, + leverage=sub["leverage"], + asset=asset, + side=side, + entry_price=entry_price, + take_profit_pct=sub["take_profit_pct"], + stop_loss_pct=sub["stop_loss_pct"], + ) + + task = asyncio.create_task(_close_after_hold( + trade.id, api_key, sub["leverage"], asset, wallet + )) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + + except Exception as e: + logger.error("Trade execution failed for %s: %s", wallet, e) async def close_and_finalize( @@ -200,27 +273,61 @@ async def close_and_finalize( result = await db.execute(select(BotTrade).where(BotTrade.id == trade_id)) trade = result.scalar_one() - sub_res = await db.execute( - select(Subscription).where(Subscription.wallet_address == wallet) - ) - sub = sub_res.scalar_one_or_none() - size_usd = sub.position_size_usd if sub else 20.0 + # 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( + select(Subscription).where(Subscription.wallet_address == wallet) + ) + sub = sub_res.scalar_one_or_none() + 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( api_private_key=api_key, account_address=wallet, - leverage=leverage, + leverage=trade_leverage, mainnet=settings.hl_mainnet, ) 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) opened_aware = trade.opened_at.replace(tzinfo=timezone.utc) 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 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.pnl_usd = round(pnl_usd, 2) @@ -233,11 +340,29 @@ async def close_and_finalize( unregister(trade_id) _close_locks.pop(trade_id, None) - logger.info("Closed %s %s for %s @ %.2f PnL=%.2f (reason=%s)", - trade.side, asset, wallet, exit_price, pnl_usd, reason) + logger.info( + "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: 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( diff --git a/app/services/hyperliquid.py b/app/services/hyperliquid.py index 193da05..6e394f9 100644 --- a/app/services/hyperliquid.py +++ b/app/services/hyperliquid.py @@ -111,7 +111,7 @@ class HyperliquidTrader: return positions except Exception as exc: logger.error("get_open_positions error: %s", exc) - return [] + raise async def set_leverage(self, coin: str) -> None: """Set isolated leverage for the given coin.""" @@ -180,14 +180,30 @@ class HyperliquidTrader: ) statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) - filled = 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", "")) + status = statuses[0] if statuses else {} - logger.info("Opened %s %s @ %.2f (order_id=%s)", side, coin, fill_price, order_id) - return {"order_id": order_id, "fill_price": fill_price, "size_coins": size_coins} + # "error" key present → HL rejected the order entirely + 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: """ @@ -201,8 +217,9 @@ class HyperliquidTrader: positions = await self.get_open_positions() target = next((p for p in positions if p.get("coin") == coin), None) if target is None: - logger.warning("No open %s position to close", coin) - return {"fill_price": 0.0} + logger.warning("No open %s position found on HL — already closed externally?", coin) + # Return sentinel so callers can detect "nothing to close" vs "closed @ price" + return {"fill_price": None, "already_closed": True} szi = float(target["szi"]) is_buy = szi < 0 # closing a short → buy back @@ -235,10 +252,21 @@ class HyperliquidTrader: ) statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) - filled = statuses[0] if statuses else {} - fill_price = float( - (filled.get("filled") or {}).get("avgPx", mid) or mid - ) + status = statuses[0] if statuses else {} + filled_info = status.get("filled") or {} + total_sz = float(filled_info.get("totalSz", 0) or 0) - logger.info("Closed %s position @ %.2f", coin, fill_price) - return {"fill_price": fill_price} + if total_sz <= 0: + # 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} diff --git a/app/services/price_impact_monitor.py b/app/services/price_impact_monitor.py new file mode 100644 index 0000000..262004d --- /dev/null +++ b/app/services/price_impact_monitor.py @@ -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) diff --git a/app/services/recovery.py b/app/services/recovery.py index abb64d2..2799ee1 100644 --- a/app/services/recovery.py +++ b/app/services/recovery.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) async def rehydrate_open_trades() -> None: # 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.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) 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 register_trade( trade_id=t.id, wallet=t.wallet_address, api_key=api_key, - leverage=sub.leverage, + leverage=trade_leverage, asset=t.asset, side=t.side, entry_price=t.entry_price, @@ -67,23 +71,29 @@ async def rehydrate_open_trades() -> None: elapsed = (now - opened_aware).total_seconds() remaining = MAX_HOLD_SECONDS - elapsed + from app.services.bot_engine import _background_tasks + if remaining <= 0: logger.info("Trade %d past max-hold on startup — closing now", t.id) - asyncio.create_task( + task = asyncio.create_task( 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", ) ) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) 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): await asyncio.sleep(delay) await close_and_finalize( trade_id=trade_id, api_key=key, leverage=lev, 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() logger.info("Rehydrated %d open trades.", len(open_trades)) diff --git a/app/services/tp_sl_monitor.py b/app/services/tp_sl_monitor.py index 37c1bb0..05314f0 100644 --- a/app/services/tp_sl_monitor.py +++ b/app/services/tp_sl_monitor.py @@ -33,6 +33,9 @@ class WatchedTrade: # trade_id -> WatchedTrade _watched: Dict[int, WatchedTrade] = {} +# Strong references to fire-close tasks to prevent GC before completion +_background_tasks: set = set() + def register_trade( trade_id: int, @@ -79,7 +82,9 @@ def on_price_tick(asset: str, price: float) -> None: for wt, reason in triggered: 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: diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..90f70c4 --- /dev/null +++ b/deploy/README.md @@ -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 +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 /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: ` — 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 +``` diff --git a/deploy/trumpsignal-api.service b/deploy/trumpsignal-api.service new file mode 100644 index 0000000..5a0f087 --- /dev/null +++ b/deploy/trumpsignal-api.service @@ -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 diff --git a/trumpsignal.db b/trumpsignal.db index 55edc3661b4f7ca39ca704329737e837455d3885..eff04d2acdad2260d85df0d8d229bb5ea58fa3e6 100644 GIT binary patch delta 55809 zcmdSC33yx8nLh3-$(H0z2}wvoSgr+09I#~FtKDQ%Y$q{z!*&Q^X}Ftg5p8IRV`wWt ztfkXJ>CB~_wovFocOWfkJ1x+?oi5M?+UdqJ&~`f07Fw9Xl$rkC@0=@HcASKnXXg1o z|2*JpOZT38mhXJa`z`01Yc^c-^f|Y!TOMs{YWfuZul+mi_MTvGw8{TBuXNy}<@TGJ zI)2h|Wrwfri*0Xfi?{x|_3vFDbggvWZ@Xm_Ffq};J2BB6lsAc^V^iYjp5fsh zvDMu_Haa;qk-#vhiN+T8=MQM&_{7jiVq%|o{@^~byP>^Jt(&g)I9CsL3D=&+hL<|D z|M1V62ZTGGbG_VJExMhnH*OSeT7P(7=N-a6T}h?q_N!%m+9);7qN0^j2aWy7LM|f? zU@TKZBZEDn^=UaW+^czQ^8ed&e?| z>yGvZhzdS+xP6&J@OCWkXzOTh|7-iJ?SE?jUHh-wpK1SD`xEVtwSTMqEA3xwKhpjH zmi@Xm?UU90c<2+=i}Ca0)k*yPSalRXKUzH>KR;3(!p{#^2l4Ym)gAcxf$C=be1A2D zpLbV-`1#&yFMhtKx(Pr3zIrBpzPs9mpYN)kj-Pi{*BxGc+V5#Ak#cj))lxmwhk#GxwQ@{%Yhtyyw zqQ~@@5>n$vFdB|0^>AE{L^VZ{WZeiSBM~hSQes9{>pLj*O(*Ezj(jAfUYIM6#;zJQ zGWqEpR~<-Boww^yDi{g)a)pE*&ks+Q zqZg!$(V;DJV5=PQ1wuYKBue4FfZQj?{b4y4#%KdeeG?DG!p*jAuh8^1HC^C2{P_Dq zg3IQ)x5;y_=LeWk)8qHs72)+y;T_-qYwtVmcc6`bZ}Pn6`FFJOTTe@~T@Y-x>T8c2 zdF>IQS*$dl)s$>{vdQza=AU~W^4#V*;2CVb!Xw&RT1Gqnqw|T*`#ay#sdsMeT-))d zj_-DSxZ}Ex%N#!uo_4&|@u2NcN3>&k`@gk+-F{#D-R)P~zt_H}y|?+c=7Dy1+jDJ? zw7sjX)Hd3-sm)=J2%m5LW$Wkc*R|f!nrV4=>n__@Tf5wUaX(pcKhXS;`_`6j`=xH9 z#n$wit;aUu-sVWQJSt?}r`TU|y)3-b^|-Lx^-@4YH(!!_jak95A$G zEEWy~&qAR>k4a(0(32ASYDmFQOp4(3(TE-n1>@0JASeqTq`PEA!!VLcK-HyK zJQ#?^qH0o(1Y z2J|Z4o;a0{Bd&zxWYmbpg7_~U59x{)(qn2EO(z49r0^WQI}i@Xq%huvmerVM1fo${ z3Ch^^vZh30LE$O7ONxi3NGKMN3?pbLx*`SCfD)Brpsua~tmM?scx0U5~jw;;P!`>|bm7TiaQ} zf7$Z3XN6N*&TRhA<{vjd(EQfsWOJfzubZ46Gt9g`#>`4XLUe41>H>IgXtcRD;5UbQefH7L8*M zNP$37jbSUtWddSSaC=|?tkg`IP+44tWgX1jwh+vL81+k^2i7lCOt5ZC3@Q;E= zFkgOF*kFF@9+zknX3Y!uLx1{{FmCq#O7L!=yCcD9JgP)M*vYUOG(hHtfer*^>|+)4 zAbkGFtuG007R>j*+a|U&y_3D+OP_Oz=Gear-R61E3+v4<{=2Zzd>X%@OOh{&NpET|Y!@HANu8&WigPl8HJ_=hGl_q=eW`TIW$!$;omiqI~Yj^~9g z-mOqnGU7URaX5y}rOKh87FLZ=G!Vh=lw!i8=1-p!&N0t^UJ#*&2+h!n1c9%lj#-al z4@R{lm>JQ0FdkOGxFthXL$dG;jUg;Yz>Kj-QY;z@pj9mzQgJu-n;cNofbes67g%dd zQnArv;h-wX*xX5C?lR;97)(HTg6;|`NjYlBs5+gUOOWB%ok2quzJ283 zzX+!~*wDg}FgP%nULc?t5h-R!ilkt}OIk>k;z3FH$n~~Q+lB2F*VRpXnqF?Q?Y2E_ z@3DW?{=T-)*$+9cYun|x)Dh{tvGelIzP3!~%8oyDe5>OF9dGWqs3X|X+5Vfhr`nsL zE55gVw*3NOUDM0LcI?>KgdYnJw*EzUt80&|*VXEL&iSzOUCyF&#JSOFYx#N05&MKK zYxB0;Zf|Kxx9n^Y+s|>?f7|?*<|l0LH2vG{C&${luo3$`r**#4`2)wYwmUjMZu@*| z(%#d$y>*@YW%pz5kGijSzrh`IpXB!1zLnFQV|e1aAu7SkY;0@yezKGwnbqQQ7j$HFL46~ zx*kxJXi|g3iO3+CxDiT5lClb&G6u5$5Zx7thjkE2MAIO8%zyf`u+|G{qJ;x`M1oL| z(Uo9OGNeFE4r{?!RL1JvV}2K%^@3`WiYlwgcoMQn3K=peQV9hWJ*q2eEGE3q-1ZW- z%9l3M=DfmuXBq9D*Q8>5nTzZ$rwmKnT$l^A*dO#XfO<- z1OG}wxpOB!MByaR^-DN9?X-(oiJGrHUbA0pu{&B%p(A zv4gNrm5}g5x-A|62Z7R%3_)jykkKH$0};%c95%pBW5Q#sF+CDDA|XSEE)xj^g7_T` zK^;*PEdmB8e3$ORzLb-pq!cxRih>QS%d#4fE=odOi)yNFB!z!uw}pdANeSYPpq`9D z^r3+Ob&%#fBn5?U(p|Bz9>;3L@CrE)Peyc2(?Zbn^e99hIG^x!bN1hb4Q!)EBhUBg2bY)xOk>EomJBOf1iTy%jvX@751v*B9q`Kj}3EjiDpA?2=c{HbLKlJ4}* zS4qe<|Jd=}mS-U9)XpuPs~wk^{<|F~pX3O)Pqq6Y)I3h7fA8d?e zVlJU+fPam9cjzu1k~JzNm6)!}pmVTS4Z2no`z-)9 zQI~};v$kS7h6VO4V>cMk*HjGyt|Uo`k18tG{)@b4DwHe83hXGzZD@CDD5UG*plrmV zFg=COu~(`Q$VyF7BnXW-IDi3)Q$vPE6mCGc-OuhK3pEzN@N^I+q)i|U6+fm&LE>mV zB7AIpg(f2zjq4iT6;PweaM%co(_C$*}XB(fJ?FL!Ce3+7^tuH64Gf8Fj9R^L;SqP9k%z{ch)P+phvq z_O`dekbAi8&bC6^aN9=LvLnOe&0Z%XFR(iaimU=g8i&do)?#rLl060vp~0Sr3eQrX zG|(GX7YYcL7OFk?OjHjfVYe8;NDw^Zak@*5hG2ey(}7VZBj7<%u#XT-4KP9tW`yuC zYfILnMm#CW{Q->xK`asne+j_G!FGlykc3aO+u~q}YBZ{XNL2;A5X7!YP`jiEwEd(m ze3b4oC6`;g>Eer;@qY{ccjAB7k&7>Ke`w=7TbkXr2b)^I-ZE`_5c}x^w)jm__4HLu zBey846YE?qW}+unRhKudKaoT@^6)DAtgTa)L@ClIOMStJUy7P9&bn83M*4!GzEIpB z2?e1SneRVeSZ%&_jeXpdAF{16^G`Y3%tsHomzn?7;c%O8TU!ZsqU6_71nA2 z-BXH&m`l>1_+>ViF|vMfNK`Unv0O0lu~MS%=J?CdP#yzf4?}&XU-120t}N;)9bNH@ zDyZw*1{!RYgAGg*a9M(uE+82`ju}BOS*-S32PZi3UyqGEiwVlo?=~Q-F%;k-2 z)+iRKW0=ov4HHW9N$VggSsk-_W!Wf}=#iOn28)By&;m{4Lrff7p_Ddb{%*B(uTuhl)Q8ajTV!7MrxaQ-;23q2^5r1=Q1Et&03Bgbi9O-fq--|qi6*TL&1CJig-tVE?Y^abfAwQ zeOj%Ym~Px~Z;4b-B|dO&BOkaWfelwfQ;q?qj1piEh&!E|Wn?~8P*SB_K^!nLIjx{z z=;_n}LmbY{o-PhuAm*_fu>p502Mi-4o@XSJg%qtm)~M@M*Xm30B<p4{AzgH#$-ju>`bkb6|t$DCcKl5QKygqZ43Kv-J96PE-rvn%Jo& zti}x5O)Kd0dF8A@Ovh)wXN#l59RIY_+00`8@p%%QV#fUI+iYtc^tUNpVmsNa zwmH{S=65670Y^-tG1`wJsbb06T365((Ty~QOsk=YJBD_Ndxu8Fq{S(AK{}Se-ZGh7b{4E0{5w}k zisKMV#kurBQUhxHaR__upaDM^$ZYS}sHo1d{gze=#G}D73)9dF@ZWB{KUbLZi#s6g zb=oZ0f*?lPo%AMy_S1lohd_q>$6m=54&XjUqPy^lN+FBhotG=lh?6r)K2^wZ1%$R> z7JO2HNXPwHcW}WxlcD0&-Z629A?B3BWNW!1u?G4K`xN#z)8G8! z-U`$sdN8{{K#+PwIu68cwhQ}7$sRyI(sOth*iETW)(&8iNp919kg?dXC4&?z(zmSc z@^&HYpdWd$Em9y2zZYuQ-l5@PF)_S1v2RlJ?;P5-drI6nHX%;!9>j=7rxKG>L;d3T z;Mh1EyJI`?+vw=zzTpcKqeBUE`G|W{ha}2!UpUeij`|}J(q>nNWib%wlNnnJOL9CW zlO~iH5)|}I|J?)AU(<8QWZeRP+}}?9TZ+LGLRRzC^{!?61AN08STx#)i_i#K>D-}{bUD`X* zUpIBeZ=I-~0hR9^r!`V%b#>EvxQUKy zl$%}W*hKR^bFS4Z64?^dzMx*hKB0Nenb$;Yk#^{6;y$oN2wT~FG-87VRlAoagK;jR zzj8jGE6}Qe8N)1KghG><0b2ndo}R%?6WofzO(Th22`G03kamG_Eh4xP&_7Xs4;T>5 zA&Ku1We(FfQO5G^N|i}}siYXKP2{Gr(vu}ja#y`ab)AZOz9i$zQkr=RgcE{VU<5;*0K+#%9BRkjh#r;j*S zxqE7ZGnJx!cp26QGN%aA=Br@7c%XTm>FQ`1GIQx>xB0@m@#mxI<~8P18Qa?fP5YpLIe;v-xP>tp$$@D zMhJGppibryXauVULk&EcX?TNBvLVpUSISAY#!T;-E##nEf*X$I@tg=6PeColGhTWD zaeAgUPmyuNZFAC!6mb$#)F?nAPC$;P=eEHDs1(Fd6y_$lIn!f6dBnp>v|B7QZ{+ed zCdYRo_95L(qP>JyfK}?yr%Gh@f}L~wVH{LNW+ir?aY!>@s)28l?wG(+Y-bSCF(k&O zNofT)Mxz1svLxQQZzkR+fKGm8j896A8hdon8$1? z&$^6PqA@&BGpB|XmD>>5-YkYJ0S1oyNoOD1F+4OiI03mj022nYN?cY~3I~`iLxaf@ z-vAdPmckjp4xfxg%BXA>_^T?g+Xzr%JbPd;G0@0#b{Sb1S1>$tX(NRxOw~GQ97Mp? zG~Q4~ZvaHW-~g|f!zj=y>|?-8G@}s4Y~q<7NeVgB$#7R8r|2}xqS}n^6no6XJuRy$ z#4C0TkM*CQ7{H*|D%GuK;zev_Egg4{f6@=hZJ`^JGpRg=KsGz|0eTkJg&QFtC~+Jd zxyTlAh=C*sX@F&psY(Wr7||{|lM%sA zM6+w1dsQD)>Z}(H7iW6JBu2(Gc?=&WLk>?)i*P(y1URpMFzPnrr@Ff;XgpWI+R{BU zMmo>i=V}ZdAc>4`lsdH$W%$K0^r9fvcR`T>d%$64=#97^h6HbkXdX03DygL!jU|&Q z*b!KGv>{e%ZD?~{VE1J76!S30xqd?TkOg9CJ&HIoHUSI9YH1Nqp(Zi=i#QNtK;kUH z7wDS8ENJXimCa2_sn7=J?86H&$oU6TCN-l_f;I#}tmlj(p;t7z10dHfKr$dHOaOg@ z1)if_Wuyru8-nsqxZp$?FiA-qDHrq{!$hDa&ZIIlH`Etc)2zK>g=Uy!7^kFT^`AsF zh!1oL5@k=oyWL#9C<-pfT1WVG;EyMvE)jN^c)#c@@XZ~8W zckKm;0pv6oW{^b;*l8eZlwpge>%b_?0l80-N#R210}%b!t#DOtKeg(G2)gUQA`x^V zC+%KrfSh<9vv-d>RYjgSAKey9Tz2UTi9Gw`s^q09Ui%)*K_F8#Zz{4ildt zfid9k_cN|BUd96D#EF!agJPtV%+0@TKBWTW=l~;3s6kMN$?z=~z;dDbFanZ-;G6(- zHR4^|c3^kZwglct3k2&Jb6UF-WWlI?4$VNv0d@ih~a9M?ZWOOu~BDG)C`$hFr;hFYWt23wF((z8TJ!*&$>%^I3Upewz!gF@z<&r0hKu z5t9$4dRFvO^fW*Q(oF#2T7t`xfmoe6$lVgODGURX4RqC-Zb)Dzqk&McQIOKRM_MrhOg0*a zlng0g=C$_-tLvB*7HdxyQWIJu*Y&Bvgi8J?81@M7W;PQoECj|uBMpBHEZ_nG2B=ysV|CPd)XWgM+b37kt{UhBlLVp5Q1SZSV01l%1ubba{4GU%gdsyZ6Cqhl z2-_P%03@HFspIUuHh;AJ>DC3ClGqltK~qn2$8gtT=`8RCqB+_IG?Bm%v53Sp}zqWYmL0d1a3D&59xtE}B8c0i^p`QaCQ|04D@Zhy&2{pw?qD zz!_PCGYZq{;8dIiZ4u+5&&h(Kv11QF@L~!`+qGsMh6hx8r92Jg2?CiOQvm;BjzJe` zI8T!NQ_H zpElocfn&A0aOp^6u5rdps#r&hFj4F)=557mv5`P1G*G~Z&?{=ZlHS{}O$n#a>u4YR z69{4t`=cD~^FX{yh-e4k=SSdP(0qTzz4D}E;Ct_EaV%>Qo^R^BukESUm98Pjn}p|q ze}7-7+_18`3D8QF+}Cxl`VDPO>rWI-x6^qcqTsz67OT+?%>%)i#y-k|lfwK@L(QLJqzmE23_A1eZ)<5j8&nP1O-`_q zk)HFBMh1*Fp?vMlRK zXhh<~4>9!?+6-n2&Plx}arO<4E5CsMBHgo_j)dB*{7cy>; z-{+b;JDKu|Zjc)(t((_&I!`v==x{AJZ+lyd!-E0zf@P^#nxSEAH`m|xmKAkAawcaN=7L9;8BV0MSM4!T7 zJCYws0>|r<>_p%*GuwmMC$XDLD9g*pT^bP7Bv;w(7U)9c3<79GU1v zf+=cjh3?}w)$M8vK)@F@kxCF!g=0wF{XjGa2S_ogbrYHh*bvjOiIBadppgH`0J=EN z5JL)~0YJs{H7LMI67f1|^vX2cp6Czy48(FW1#O;97flmXUu#nWMla34z~UPd+Q&Te z!z%~~;pQBRkRrnaqr;{mqlJ4Gp>8WMFu<{y6r@+cpOsA$_y9c-5frn8)#EX;V6c~& zdWdaX*NaOtD+xxdL3`YbU~nDbLgxMpM*0E~ixVyZ2!QED=>|k3M}nc(1qfWbK{&ZG zRXqztcikn6h%Tg4&B$UDnd@+FJJ`49HSVX}p zQQR3+f$;|KH<_Uz7P817NZ^(s6A4o@Gbi}X&pp(9P9@wcNxf3M7kep_huF7n^nqOK zK6D>!1|#jGs4b|(bn8ME6-+2uRmMkpm*gWSiqN3e=gZ2cvEmg05=2>cWJS<%JoB4A9ZP8BbD7S1nuPn2?`tpzapp7 zmVwW>rcdmdOo-muRF;$rFS8*4=j}%P4vYcZ=|wb@4!e2~W+ogDmxHLhgbQnDO!6G41K}^$Pd^H<%h21{+$o9 zVURS7U(7=TU<#BXPF)CVaGJT!IUq`0V$UQ+^|9gkktp;A*U4t!5n+{i-X->xtDru> zl;LrOTHWD$xyQEByz{TFWu~?tu}v%P5l$taR5J^%F(1Fmy~<2E;HQeuxmUz*%2a!> zAk{G}NG-N?#Vvba9GoB#%e;1XjN8z)2<(Ml;`ju!9GS3u;DJZ{i8=R>Uw#Fo~SKb?XQ5Fu8x0qROG zEHm@Pb^qe_JC=8Tvfg&t7^ZS1L8aJZC z0T+NW%?CPNaJ?IdKFeR~boFF%WLlC@VY%M{*08u>D}1!BB6LfeEC&uO^U+&>W_dARvVs?A^glY|R?g z*6S~;p0jETMB|DDOe>1mgcIZ_Od%vEg?^McO4*K36nKB974|W=0YBObi2Xgx7@0XQryr{NZ&7F|2obBd|d8hMzh@<{uw+tk}Mw zYuNC$2m=b+JeW%}vv3a|2E#ta5sFB`4b*qSWz1*%uCvTP{@!u2`NHq*XPb}T;hN`5 z-b$c)Hu0g}#yPGcBAc_P7VH_-{ z$t_9-3`MCy!?U6^PMQ`E!oN-0VhMO>1}+MMD$cId9xe8ki%cC!`>YrE*bA^Aa==%+ z%eUwSc*2ST@v-KYUb%4GA4jV4QvRJzYawCRAfqO1KGW@3Y2J0O&>CV}&CM?JDu-~9X?6&w{M~k!qebw%+T?i^wF~~>S>7zzRv>S? z6`_<3AFpn)uW;jIim-Ips!g5Y<`39^WvfE{DcUO6?XL!*Jzmzhde;@3)}JU3Y?$OS z{OOjsMhpr7dw_Q2$^jgRbws#yxpb6VMzIH|!I-yzOuYNjqxE1Uf`pI@0^?so${veE zs&4@1Zk!_d3qzH}E@AW=r?3U)-@cJyiFt4a^WiaU-hxAr%&iyNdPs~q&E0E-_IS0g zDj%(2UDmYzM7hqE(icZKJ2EUI$fgek7n24JG2SOd{PEb*ss|q-c70F^p*n#%u+Q~M z<@S!Mgf87V*SHclNloidRDfYOrhx1iVgxle5r>qSPl#rSz9QfKA~c%_8-OoO-$2C* zw68(A6kT;J2h-@_g@Y4yM<_%Q#2>l5nJ0nX1qM#&J_$*YsJd4Ht}Wo!3#Y}h&3RY~ z&0{@Z0lJ^4B#ANMH&i5|^aTaAP8K8XVm<{mmx7H6mLnAxkU8OQ;KJaEh`3E@y5nchlN_zH=Q~@eLYTA6u zJwhjBDa{u-=BF*N`yjsO(Q2b(;uyl1CdB@+J)=`7I)w0P7S=R2F+3n5Tx)V_&-l=Q zI5;}CXV-3VY77fFJhm4AJ@*7q>I1w@@PJ*OaIUKm5Xsg6JRLNEuJq%zL{KSad*gUfp>~x>R^&E*D?d~uzaSt&b_k2ojPO~fUFn{vA{(Gbc1DAOr-L8 zno9&}G&(J)10-1q;o*NN{A5>@U`#*g0l~ zu$_o`p?8DU0dA1*ge?WrBVd0JD$l`VfELzpYNUld0&RnmL*R~sx>@gWufNr81Ok~+s}k#41U^O}PU zbAzw3WJ-p%3Zq5t!ef5O421>DKzb3+gwartIdCr)K(L?{8ri_muF;{XeO*Mg=HtJ! zof_m!BWnffd&g_>5ZndnMN{ZsEi8b(wGbm&ru;i#=W@_59ZQbDdV2ctYK z=9=F+S9oDcz{AF*kC+9b zEhu9aB#93|Op9_@%uh5s07TB)$+)G_jleraERJ4PCpgBdYtxXkvSxUsxsuIdSu`SL zaQS+Fh7D;M--LcNIaGx9F-4$u?Sfx1%>iWrc`QqBi?M%sO-uH6&T zIKBXCgiA4ubYnn`2!UTpf0y9B4fKTqz#;+71;`HL2q@WM@E+%i=GH-B?Wjf22%sX< z<>)!HVv7JQGf1@V+2*X1O;gPV0edoq=H`v&=Swz^dHHIxJRcK+EH1eM<&CAw(w?Kv zEQnLSZmt@I7Jp?UNmN%57IgdqKi+*~d&K_4=*B5X^^x(3v5}!k6lSpQn+9xKE z#DgzJ#zuFGlY^s^gI(UEv|UUjA=j{1NuggtpJx+^#O_E$26dZv4GCRt!UP!1X1@4) z$7-@GPchryZENr0am-w2VcO!n-e_&~dfZOQl+JQ=vDq=t_;Yis`G;-7^7va;S0mUS zHDP4y# zFw$oXmcq&MY$lK3Lch3BHVzpYvP1DSB3mf)GgZl2-VG8`^D&8# zY&^yqu~Z}-ne@SjR%Y)4XD{dJ%z;w7t)@oMi$qo`n8yNS zixy$eq>mwXa0#stt@Qmc3zhN`rdBH6?_w zmZ7|v*RFA|TW)D;^Psw^yxF$8g}>R%{K&Z`Ou!(2MyH`BVXjfaUlAA(hYWiy1d)A4 zp^B+;#`Kk(vYEZq**Zh|J%lvHit({xLUI=Sp#>VgJH8P)wQPJ6xxkmkc;MD&&apfgdn& zR&S035)l?qC)PyK$65dy#Tzh=_Ja|EfSeZi3ZYjl$x|6d5{v;`3Wnt%2MJB{MSF+& zfj`;1!lV28q17UJ31SD>0FozYYA?>s5XQV{DCwGMZr=V_%erPtBu|^S-`>2IOQ68l z98O`>d_#+Hnv1kz=!0{n@Mo09+)cT92>wD2u~JT#76_yp*H=TdBny<3n<0_3T9J}t z)F?qj`smQ;E^*iJ*u=g*aWC<~acl$91WJ~h5_txoveS?v0ehYzEiLK>>_AIJduf|_ z#w&Kw+;^kUSpli?$9<4pP!OhbK1vq#0qv%zeR#~5!ZSxnHns|3@Fh~)mXd6I6@u{C zq9h)JY8R;pWMZ4wtwA>Q*q|V*u!M;dAa4?3skE1^JaxikX?RG3Bx*b*w7dFWMOqV-?^GR5pYC0bY$r#@Q@f{#nonS@WC; zk$yKuWD58inQf>twN84%jywLQ#`_ZO5 zI-fq}us3kBU3LT<-44;Q#(~npjwbsn_808Gu|IA9iT!c=H|>wwKWo3=exLnr`^Ix5u^+PMfqq`ta`@4Bprrymdl~+(|0DeLFRtz;&nuo6JiqZg?fHr4anCnBk9t1q zx!-f2=WfrPr~`Ym=Niu;Pu?@*x!iM+XWX;fv)vQ(1U%gy(X+<0%;WMjp=jI-oxkaP zy7MQUk9U5v^U==FqFU^Iop*QM*?AjkTwT+7s59R=)4AvJ&Wk$7J9l?(?~HW@I=ef? z&NZFOI$fPj9j|n}(D9p&r%@IA@s4kHJlgTuj{7_A>$tn)&W_tUZtl3I<4{MwW2WPB z6u}$s*xj+cBi0e<=$U7r%3V%J{^zhu{62)|(0p9?={*PjVLW7nSwKV{c{68?!@eSZFc=f;UC%cTf(>4^_#*s+4UR3H`w*-!q?gLYZc*Z?3b?!UuD;?2w!2>FAHC0 z*DncQV%JB7N7?le;SqLySa_ISzbJf>UB4iFfnEPW_y>0VyzqH;{haVQcKxjIS#~`l z9AVdogooI*@)_YX?3YgqpJvy?!eMrOP$Jq6w!bjQlBf>}6^*-S~cKxvMVRrqH@FBVm3m+6d$UgXh@BwzcSGbp5 z?-5viYT^CD``O=j3wN{YUBX@L`aa=(?D}5ez3loP;XQZ6LVwft7U3=I?^}dh4nG=t z#j%XJ7KCp!b^MF_hs}G1Z-DNO2$h@KtD8v^Um%vNV?eY|P$_r>KIs%}PG}g}X_lP> z9*-31fy+9Ya;KnJ4m71Etq{tu#pW_X$}sZ1<`n~Dl>4?Yy|I?w@d!_w%v=7i^f zb=5+diMK*aCmn{c1~r!hl;}+WjiVSDCA4Al!N5FWTJs+7ro>(gI-GBk zv~2bvi~-gdvn}x(IP7kQ$}ph_x0+6{SMB}0NoEv4gc84|b}AObruVBDM2|@De%E}G z0mmLG4BUV)hH_+uf`e)E2_nk?Rsn*FIqrxrtT1}NxC^-(s8ofZ(;N`?QVua)ed0{1 zlrQ%6_9}iY*L$hn8wgB}TzTMN=_m;dHwwaiB)^0*aZ5^I1P($`lKm)H8;!)c9t30$ zJ)j~lxdW!qYg=)Sn#v&*IzIyxa|ujr!RnZShis=DtGd{!m2tS0KtRYG!c3*KUI=o; z>O&GUhb#;>5Gm8v$udJMJR;m0J<>NHs-BB2@m7ifu19oN_d-)WLEYGdI5D(q*I)(k z`tV@?)DTScsj;#1sj5f+pg4?@6yhk$3ba6Zo;b}-_q~IO;i=tJsp7(+iCsgeQh}Ux ztR$9#<-CEAYgsoG>jsH!#jP;s2CDz05a`b!;c_LbqKFCx2CAa)I64A==!O}ORKWoC zhO#iw;mp9AQ&J7Sr3ML4uH&rU5mM)evdGFr@+fk$k->=sy1ndHhUO!_5cEWQ0xlfO zGekz%B?-KPUO4Y(F{p9`tK(y3aP$|j?)dCNjyiV%H#tO9n6&;fo|#ijmJPRs}4S>ziy-SW5JK-+6o4dTu5S4IYDG zgCJ`}m#`EWKZe^+ST0D1boC@nHrcz_2+YMm#Nf5InBQwYBic72Dv^wSGI3)5h=hu( zODWe3HlpyHcrX@cyrsDdZVOuANd#I#IEcf@ zCMzO>epf-6QINmRqrrJnyj2nmtOUw3SQu&JM4~88#ly@j@}7Xl2WU8oQLwlOa+Wdb zCif=sSu5$BCn2vHS15&>V-a8!34|7q+hxkUb^{E&_E%l)?t1>Gx%ySq1#gfBta=9G zAmELJGZJ)Wje~`n*RzXuBk%>@9l#E(D?pKGV^InZBquxuI(ZF>UQsU>m?=-h0bq>$ zN_3~5ItU`I^B~YO`q#*jGUVA?=yhe|*)uw{b04)pg^3u+QSKq1aD2*YB3Ye8{BV-j z6IkrtT*yn%Kqp+hnu5*22+VK3_(q%Gd?pH+@V)138!XC&TL$s!v11wM96637pdIm< zERdH`?k2PM2}eu6!l#kIy9FzW&=#ycs6^#!hp-sT7&3H-f`+hooiUL)DKKo(uUq8cX? zeL>uC^%4a2qZIq`iouQ-a)c}u#2p4aw!pl1^n)P`5DKMR7W&RqI)fn1qIf>la;SjS z4^WYW1Yn`Z`ckn8=I66a1qLwU5V(al3suiPz zHOiAy$$LGFD&)cssFd&daXtMbgJl$+PEn79Y7g%Nx@MUhVY1a z^=^BC7pMO9t<7SKa5bxFd(IP7b^PWdzuRqpv(R;d;#tDoN0R5+?-qD7AMJLdnYXi3 z2lhK%;>Ky#mE%WahlNj?=M3BLG%p>nTPGHLqAQ>JHk{+Zu&c^-6d# zPf#qno-jyJ4xG9zwhJMIvE?|Ob)CI8EWTZDB_evo|$THXt#gEC|0vBpsr>{*o zBmy!o0DRIfq*gGTno@Cm3QraG0e}M=#Rn>!4e+++QYFWF!mm}$@D2Vy1 zSztVv80yqq9t41JqibJ-^!7c}?6pC1p8B$V%_s_mtT|ukD z18q4z`LkZv84JA)mFjjPh_(d;VTVR|g5x_D5JbWPvmrV60#ThW1KGoHh6Ic!NIe7& zp?HFUa3AF8PoUCMk$c-uL=nM8icm^ib`}}Z-H@z{DfgjCdJ&LZA4-nWdwfX4Dipa> z-6Dpa6hC7eg9-qU#}TW13@U&d9aRwFLPzielo8vimA!0U$t=L;w5Zk`9mM$$w9o^r zI2Cjw%TZ1lpR7b_Z>E-@S^zMP+E*HiwbrHyjbWOo_o&jUmd!6+Wk1!tY$d9Do%>kJ zrZwPR1PY<}pJo-x;z+Z({2m-aLn#&=!~w{~fO{0xJavonw0QO6>Neu0QsY9CR~;v% za1nG23B*0gkW6vJAjmj&DIkSM;U*RbQC;aGsGsCTu__r-kw-@8u# zyFb`l$@%U!zp_m@%h{hMi$j|);>z5gDb&nhhz7GZYaYIma)?SX%`H$YZ(32^O7k|0 zc@x|aR!vQ)y1u1p{fWxhGu4Q4{VW&ZESnC)&%NYg3~}zzuB@m^unRa9!OA$~h9EY} z8E$SLKm<~#SC%R8oBZ}fxbXW>!_3ix@ceAaXF1<}G5m>UeJofLVf{Y%MG!2f*F1Tt z0+=a2DlV}B*!+{e7$81qd9Kfd@>=13>DmH7=L}Fj7W35cVKzFZ6;##3FvtZ0mSuIU zKHenY&>SEc)I0~3@^FWgg#FCTK zvP4D&OTL8j|8j-pauot0(3Sf zzwYR&Ao>aTDHZjox!>y(*68}jxHGKoFU0FyJtKy+Cf+!5)~F0M$v9rzmL`^}r^9~2 zB*D?%sM8}leG4`a(yeqD&v^RyDI!v5%8NOrr@{N>%J;=M+*h|FB zo#@Cm*2|GFj>rmyg0R=XnHFWn1V!M!D5*h*LgI9sqkD-Pke^Mdj4vb`EvtbuqVC#Ej@*)DNm4g=+&SlPNP%Ap_{g4fA|#({w79u@))A_gsFdZ;Yn+Ov5H z7hrTC3dF)Qvk{vbNG0341fRn0q_|Yds5*_R7ou-?4FEnw!(pS-E&_OnB3Ser5g|ZW zfI-v>vaJ!8LKdZ$sXeS{JV#<``O=_LiatUyA&LY7+(<|)Jo{`u_^s$2$;^Y$(KpiD z0bQV? zcMXj7Pwg8Yga;v$-rBmk%jdfUVZH$Rh6csxWn0C~^abVc6^mQE*_`hR%%KsfhyXuQ zhL-`gTfAWQdnUcOx$6?ZQ>o--KHq#>Rzsq=bX(E?UQ;pjNfawfw-x(uv^C9!h~{X_ zOO5mB!TEf(0ao}3fvM|TTU$3X6>IBszEnh2aX&&j#5V%k4)uwBpw z9C*F7I_K8gIbJgt`Sv7y{XiZ;+i*72T0%oZgIfl1QgOrUDQ0ks7ct>E!WsE1abp&d zb~x!7Ql*mPGW4itD3hJyx*MLFP2r@3Es!lpWJ0Ms)KKAk$5YBst%CK zu?5BQ=1?YJ2B$Mqrh<2CD+oz!{DvV*D82(gDox|&M4$Hstb5u0)GjK?*S^gh z^3jpkA`mkvAf~7L@JobU5BZ>9;W%FQ=`4$o5i=^5nZKACqko%cq*_}j<_PxUL&_sc zb`>*y^(R7J_wvqHSl6u|n;YBEkj{!YVtBQ@TNxe+@`3Jw-LScL z{`uPDSSD%FbYP_xenR(Nh|KVkxEXOguw%A(XDXJzlVB3Klgc_05m9OnDpzm#&>y63XpuVP8;(_M<}^=nGVbL%E&l>=xR&4LcaM>8QRFsD3}@G<(3y zNi$1fby-?}sEA}z6^N1+c42d`vXucmEYX1nv~q#MC~ki2M-7z`qqP{Bk}v4pS`Wyq zGxLDBXR5!lxp%?CoZOAV{LC$AY7V5RPW#}p1=9u<;g`8Z#L#&EQB=!myy1!Z`Zqqt zHptXVWb3Q37 zW-53Cik;DmXl%GL>Zd}I^alt0Q4=eTigELQvj?p2)P~P#I(#s=S&=TPQR|yQtppfG z_#L)>tyTUP3&*Ok*YWaNY?zJnQ13jpEqDUnpQ*DD&T|%Qn4}LCKC`u{pu#BF{dmag z!wdrNY4P~W=r>5=3eYH2Jo0XB+*Awqq17xhjxmR_I72^Bh&h)=d^C7N68x%HruVSl z84trBOmE>ci>xU8;>)DL&j4SQNa$3kH?EktdRkN$d_KhS-X*ncl(a3%|3T z&ID-MSZr^YIFFA7xnC3_#B4(BtpB-l+CNPp&RNHm=`< z$ea_LkGufF8iQ`R4{mxm*ePEb^~3QtN_B(e1d2sAw>SjHkfBvj$C_!iJ0Yr|ArABt z@JU4hE+`|grtSg4w{u6Kn;E8!y#;x@PTK-<9QD9pgL}ss%@pgv{<^qFO+lQ5*`sCKRE0J*ZX-S4s%Agd_3$ zj+N`!^ss1Ra`w`YNZDrtK{f<3Kadj9Wv+Y4wu#|W^SR3uuGqAc&{@o^=DGZTn_nG| zU4fVVpYyBZu`BQri`g#0ujWPW|BA#tGZ;H}vDB@+ezChy>S7-++R2*5^w>(hK28c< z^8Zs#n%!FA@?Q_Pm_b79Vm<--1fiocLmlQJ5Y2ik&`QbphyKiJGV_Dh41F66w~9P(wNaC` zf1C4L0eT{^P(^GY+cek?fL5UH(McN%fhB(AnSXO{{^-UQtwz0ltS0rnLdYK3S?W%5jKkXrBtB5-mURu0EfKeEO#v)32UJUSOvR%fl+$QZWO7;H3e~2 zs59Ky^={ojF28f_eo@F~XjM5rpod>}TEr>(oeXYQf`M=G; z-H|pdmVmQv@$x@v_+x$U@>VMSQZKH3=Tl2rnoeHr2ST2*|2OyY78{7ovOy~hTDK;k z22$3BnfGp$Q0y7$(@2_={b70G)SP&BPdK@~l}1?Zb<&JAo9ynb! z3#Xh>P@T_i=0_H9?M5sqbG>$Ny1MqOsN;`=utU`?iXTwoV45Z9@0c6XyMbxfXsSXR zl4yBTk9OP}Q#AtfZaP=pu))iAKBF6HSD&r54b6ZtWMp z0{w46w>E50mgwrh*hoKjgbd@w4AsE4bsF58!+Br){o3gx|z%e{|Q3}dK zWKaMVy906NLf&$NRNaj(-LSwrTaBXD9F8uPl%S%bKgpOBLvdmy5C;PZ1r$eaB>_;Gn0tM zn;1X=tMSCt#MrQiBV7?zjr6Q3%8!6kb#drBLK1NX66%hi;!l=Njad#)J*BtDT5xs~qs~@rGWvSM94sjU>sTdSTa7K_gG%_+c zFodInhEZ^1d~A4Z*U-g-R6h&VwI&CxGZ3lII2LdcM=zo#$B;Ne2m6kW?d?K#w7F`; zy&l0nI2ch5(>Vx%z*3%lb^|KY!Dk*u+2ClH&PqJh6rXaeUJq_Jjb0Og$K^Ki4k&Y= zmjch>r=S>%MfnmJRK$KBDs*&g`CI(eU07;X2)$N>WA!ZtLV!*XTbeGgyfa9A83D>_`k?eXIl@74?VH6$H58Y+Ebi0F>w*fvbj< zIItR_C_@T$p^P4!3d6r-&T<3~bd%$pO6O4PR@Nd#0-$UC6N!saL~1V`w+^oYtFg}f znv}~yfwVMg(cF1Y%SpYX6s3@YRt3ix775=W>IBn4-Ut-&rpze18BRomJ0h}m{O7h+ z<2cIdAP%NR%naVKfOAwfSalZCPzwlcL(n4CKwy4V#H}{Y*Tzle z@=NRpM5pTD0om?02RymRP2+T z=>Qqc$PxNpw#DYP}&x+na(PSqLTf&-nB_$T8?AWa4_>DMh2r*2QhlxYHXan!0 z;|50&&MEefj#>|u@C}_W2>H$8mb^;#5D9GP$QUuUD{%^nukJ>a5YmvX)1+uFz%aoP zumkDou1!3AoMsuP#c_)rB}wV5TEy&9JGECJ4v=aIg51$G-H3o#aPK0LcUZMkDu~NQ zhaR8-7Quy0&cg?JsU^nL>Fv}6vbatjStgE9z7pNYPa4EI9QhnHB=@5g4-@c7!3W6`)f6O&^15m zbk|SCWPRod`Ng)+{vq__`<3Sf|8S%HgBKEokP+dC0Lh?KMDi}=(;fR5ifVk_rtYcz&f&aY;cIT`zKq zQ$E~)qdpO#z*mcFSVgN28CQD1+!_`Ovw-?uXppECR?~f6TTLN03=5anOj$(D|*ikl1G8gGV?m z)*W%rOrK?S4xntYD)rE>NbO?YR|Hzu3-}idDu|0xtO%mXYP?jbC72lLvC?}iZ}k8> zCU(9=R&RX@x;HM~p3FvJ`-Dkbc8m;{%hV`IcM72d+pri&eJn*}p+GYBl-?#Ks&ja6n>w@Fru0I#{RY;YSp`;Wwf(neRkS@z=KuU&{a8y%~UU6P- z1|kf};SeXZ>3CHDu5TAo74T8M+~C=$`agdpq}q6#JSoj|NH>Y3atYn%vrh+q5FX4_ zFhohuiIc3{JEcgD@dJh#2ccMLEHNT9EqAyd<(Sc}8KXjV_UXZd4!{&(03}3%G!UU$ zPGSA2dNVKH%&RYg(h)M3LLFbs8CW?GXp36YETLIZ(KXN&KBYH9B<4_TqsI5ZKk2=- zC!lVlGBb6I=T(nT{sGo*FUv$oBY`M8jTg~Jblf-{7lc1~wkN#-M+b2g9xZVG1(n6( znY>m2H(Y7U?ARynOrY?_qUER{)|igWEm4#)@0VpQU|Q&8YC2evI)EBuJQ5l2BCVLB zAXs(;_8fF1+D`TQ95r!71wfWu5UM-C);qKBsFHw&NUO?3V(nFop;IS#u@I|ZGp2&o zy@n`cvp^>{t5gA(EEAA-;6~fpAzpHtF>5{}$udf6P%Q-B0j3;-TjPC@^(4g69GfGe z8OnlS^rac>giZ7MjH4StU$bIwg`p4R&A;)&K=s_Z41{x{G z!Y<*ZoA>E>Y}48cPu0wQMTXpu_9V8f>F?jTYR%T6PxWua6C3Pry#JQwqul7LPjoxv zS76Zj&l+kE({N4Gtb8sVq&hzkmMEHTQyIhf3i4@%Ym+v!TrQC=sy242w$MNk+t^n6 z&mrM`#@{>rBh)9cwfRRU&8;JHzDosfQ1u9NJ?&^@!G( zdhaUYPDC~o_R)hfsD}>$u@X8Z9u$($bJq$^gU67!f-J;dD(6Y01l}p>&Nl6!zKnk z5L5;{TFJ5<^y`U+U~0n^^ee@t_r)DK!4wm*fwHl957KyuuaR*wq8__sC79NEi$_uw z7lSxQDadb~40m`xCSUeKqPcY5i2Wp?PzLEIP)Mqc+%D%{-_12bB=Wp4DT(8l`O zD|sqBQ6^D6WoO9-(%;Xn%N@JqZ!Qp!-33$}Z2^&bj5I(rb%`!4(k-~2+6_u=R z85kt^ZvNSZ-`KY=fIKLgYJaCa6YV7Wr-n3|1*}j)0EXDcC`hzzlMWXv&iF~weuv<| zVIn}JEm);C-A5xbO$*u{Iq{NK&2C};Y}CLpxQHW?l||7oAw%XD!hl>Bn#kKJ_u>h4 z(9aVGrvTyfNOU^}00-3co?1|&A=I25&*l_Z0+e??n$Ws=wpQ(b^}ZL z+&o5g-l+M828fjbJpC*4#L}brfZDVbW@GS5y%*EybG?BA<$oZ2J=>gs&v?39?=|~rv8@(LHHZYN zph^_~WcfSoLFF>QLP$2Ix)Hb@ZO^~qZfOit;>pKHpkePl^Tlt z-llS+r2?H%CACU!*^P^+yC#1FDMf7AFeWB2>3&7&yw_iIRG3mdh;T{SqY0OSkxA`A)DQZ<#U&^cP?ZNp-=UaOyUdtU0> zv3WRoVDizuj9hqx!<#xaaco#rNA97Q`jTshlaCxoKBm;xu)Fq`G0mIaJIf&Lg%IpQ zm+$bxg|LRdFd;Z-d2 z6@E_D?B>IMA&a7JCE#`YI=djz16zu7Qk_x0R#Y-rL;cc!hwZp1HDR8I>@WeY`^-q} z_9^1JXzEedgDk_W*RpHM?Lv(#s+$g;;@V8zyF25x%Orqj!gKz6_P06{-Dh8qZGXgH zc5W<1WjMMR4qU2}<5LF@CQlzcc;p0ziCa!tGG|H)A^O3apgB}Hj4bk0Yyr@LAOBY3 z@dJF`b7h5*$LJZ{SR>y7y_~TTOjDd?O;e`G>}AJsY^|x8eQ=){=pR-DN%*J;k|wyE zN>QU2W?T{EC%1h)@x=VMuKiZd9^N+86W$F)quCPA$&_DiU&F>%on-{ynNIgWp z5RGy{Dz9O>7DZwXgb&HxZ#QfDO)zOMyU)KK+qP-hZlL(^+}ziG5c?T-`P+##?&`M_ zcMlu1)Y_O1#W1`D%bv{ky4SuF+vmRc)5Lx5C)*R7?vAk4-GG1d9jf%6V8^9@XJ7*} z-a#Kijf?pqC}z~%4?(;?)l(c4IWTf4jDeB74GHOm;Do+@+vQoP#LE=e`@;deSHg{pSrI)PHlRT10yr zS{<$;7o?7phjj;V>aiSH^|@86zmWKs!EF5JSAC8?%U2rj%{5P;Fy3A3Q5X|wLma9w zP3Epf6Q%NH=?WzdD8xdhl&*w1ALW|oi64&B;@yRX-GXQ@b1QAI0z2xp!boe6Y`Ku@ zAllrDTiVwm%jK*+<#v2%=#EAvHP+i*ZC^vf4`6KcH6tPL$qfAfBbhXPrL?2dUbKuv zASeC~{-i}EA)D7F<9mL6$H>L>)jzrRyanF(k^HdBw?I6f5M18;B4*Q8wsn& zS-pA;UAP^-mpgu%Si7e4b-DqYH?+Jl@5aputZ?7|PVC;!)wosNr_9LM6~X)Wxrgh0 zclHOV`(5L^u`Qs3rm|YL2@mgjdFz>%Cny?MMJ@4>@cCx!s9V~5s697Nz=T-7G5(4( z1QH>(30=b1g2$IF&3o3}x1o8kIfdB2aDR^z zFi-GfoI`KBG3n6$3rq{%$vh$KTk=GOc6eNEHdAO3)|_Cdl|-Ayu^cDaU_=8b=-Fw~ z6!ic8VCZ(nS4b{xt~_nZE4U0TsY_+ZDKJMm)yb!t@oxu&x4KVUUY*(5%9ggsoyI!) zu9Xs!i|U^HgZS8q9!7=c-sW-Kp(EWpafR8dwL&(ilQXXsYhkTgA*ElXC80=(Q@)U| z6^kCT5CIJu>8Glor{G5NYl3Kje#g*9i?9JfhCpb=HdGcv@d_2Bof;v7GVh~Od zfOu=YYiI9wkpF>!!_WxN5n%>$*G?2pMBz=J8dZ&}0fonWf ztl>fhK@I;YECeXaVl`C{isc$J0!q>B7KW44Enf=Kh5YhaZmo@i;UUQ0Pz2bhEDWR+ zE-=eY8|8lS&*Nhop2fMQ)`&|&yhA*N9dSFp9zW#zKkqp=c=2`a+Wz>O)AAr7)tp%Z zZ0~7Zw0rB@z1i&eQOl8TdtJOdnn(J%+0~ zHy0)+h_=WAg{T&Gr3%^pl44zc7w66gzJ@B`k?V)3NP-GPQkq(yBgBJwGSn~@Ti@x{ zJGPcUe9Rm9{)(np6=adLfRxs+>g;|=5J(buDTcLk1>zcNK z=|-%{W4-sm3da>XPhPaz0>8dcL8>uXnE%ICgJ-azLQHtc3S1zp-e4%h1sSK)es_Jw z$`Z5*!;NTT6)*2sv-F(hBrx1`CD;7yF-f@G!0b&A;ND}V|aEQp5@6dr2SiYbMOPj@-xgA zI)oVUJREB86~l>d-x|H3xy1sFENC@h@JXA+$dhNiji-Q5#Mrc`9p|mOlaChmeno+irAt8jJ62v$cvq7Ph${9VIXk548(Bn;+d61At z7hOAhbgwqMYW}AecNsDklS-OzOCJAw?k?1+Y+l@Uv<;kwGXXcO1wHS)BohE*YU?+ zn|yJw`8JetbAOLg#@@hLSqf8?T0PHj&~zaLp{b_pK~St_Ye6L_dw=|Vlm|nVAb6nQ zOk&%bx8!9wI}1;-QkzS3Sv)1I&9~m{#)SgQoJb%eblol z`LzeTzkemRZA#CD-feb%r1eNX*~JHwOQv;x#-W|Qjzr3w1yOKQ%vT!4+XzxQ_sPE< z+~TfPhgQ3H?MtjpT1^`%vt8wAlqBu@OvRqQOe{oU(tX|+O8#hYeeAFs`%vPZb**nt+@JC;U&!#GnpJ!li6affa6d~?^$>tom#%BDMo{6kTawCZs0eFJ`V<)(ZWE! z2t}B>NZ;(f_x7P3Ynjf;w~-qpPY`>2=u3?&4>#W`fjiwTaLtGHex+Qk5Qh1Dk=n^} zNb)vUt_M_SRN>yNm+^fs);Nd@ApOzIHAboc@?-k35tPtdX&VZMqp(S!$&;AhY4EMX zb5-=JF5bQdz%=OVg!JrQ>=ya5gXeJwC@;#EGRcu76*<{4aLLxz(E~EdXt(LbGH8cw zqj>h_7bty=)&u!gYX;m!EoTHnJVN$V%Nc>nRSo8nCT8a>U3il9@I$|z5wbGGZ=TZ- zJ41W<#g#VS*FhFn>fptz^P8Wd4ic{~Z9K2;x7WpPd9y7v;&inYpt${p$XQ3qC61wc zdLJn09lsIV+?kQ%zO-lPKKG5wt2duA$DYqB##7Nvx2#f^dz!Oc=B!eeTd!DiLatuT zg}8QUQKM!{=~{@lS`7=Cs4lk%%{{s{D5UbGWs?TYTLs0`VBr;8m(8t*bsLPc7~~6? z!0K|v4P3zl>$d6{JfzB2DA{s9~-8{)6UKEjChpqoz z057$2zC_hixeyl0K_!oCJ>C0Jm6!5pLH&iaY>ZY%*JC^vs!0f6tUcjA=_XW)-5RR97Xh7eygcra{DcGqod#s9Z z)KbM_h8rEiU|b;x&9)9nONVUV*2l3Nn-@Ux^;s7lv8D#iBB8rDLCbGXPNAVd_Jt(I!DkffZhsU7CHs3c%gPP z2o>9~J`Y-$?_51=Q0QFmS;`pvW%OI4f1$>}Cwei0_(^kTdF%plFK2Zqe8y(T-214- z1%64wPON9dHu}QSS+qbdgo3#Tf1zcLl!N4p|SC*$qK0hWf9hjklfXCb=;@#@4tDy zjbnz-SchpKlzZ@N@!L;DmU~yv4o4dd66Gl#tL&@ooOZ<`q$D5$)!OYviLHNrmN?Pp zFJE~}VvD==Tk(Or#=27%V^LO(eUoYl|f>im|MX)U0NBmHavQarF zO{H^aa1cGUYo<*-;Ni;^RQ*%2Fn@UFYHdfqXud1WgJ9&k*#|G%YL6C%4JH=fIMoXoY^(`OJ z98o%PnGsqr&&gbmR+8qV5i}Xpg9^~?@K=cWBrA)Cs0AcIi}PEeDeT-Q-F6U|d)VQ9 zg!&H-ZXPh{ang_dq?+8FXA;|d0SHnSQhR`w!=QycOa{#)dBi<^wQqwfZ;uTR0$p>R z-Dl?$8}Dl8MOsU<$}j4E$&2|h^)7r@|HcC?ZEF~h@>@}<2@moYUo~$yFP&l(TZ`$9 zC<|J3b61D9ZHSBozbHGy0#Q%OG8EtaO!IMaFxj41;0PnOGlgocP^x5z%7``#nF{5$ z)q0t3%1jC>@ozq#*w|Pefuw1m1am1eeSBp_r(~uegi%n2qCdQ2l1kQP1?y^0wzd@B zG;-|VNEx-xS~3%Xyz^zFqdGCj-?KVHkZNQZBJ1k!|QgI;mJVQR#4Gq59|LUu^{_@b%u|~F8)j9M^;ePo6 zVJ=h0x&&GNluFD*spP6bIbQ||N)_t+Iark&Oj2I6u#-dweI)WC(SfF$=Y&>!>|1W6 zk2C9vAVi1lQXqfWBDb*J3&xBp4jM;cgHuZ>)sCc%EyN8?8?>t>rL_1QbIU^Zv96RZ+7{k zv3r(t;49UkG!^5Ry3w5%f1z37e0mXsE9)u2C%>3wrhKtlt`q@MsOTyLA&^R;UMTtsvo8ju(CN7_2a2xhq7z4q*CUl4Hpqy_iYCx+IX zjsy!0ztRZF_f0g*h}g?L+>P(KL~C^rr0L-g3x!Gv>^ENtN@aq9fHZL>Qx1ymg)^%+ z>7w#SJKGaL{kO1(Hvl~}=+b|~*uxuu9=85J_V9lbdteM~7UBb|lbow9afg=OWtI0b zBq~>8lTh>TpkY^?= zb07QJSjK&7PvR!mIK%jxKmLo@h9N^3umvjK-uz@+ttd2$&N;SFI9JHWU9>!1U|lgo_4 zRB|Ij01C|%BkVw-P;3hidh~wA|nth#Xtk}!k|olV6EiSIm~0t@KZ+XrK$Y7 c&gW8kNeB%!>ML&+PMMk2R4J7%W-Y_??@hxMIsgCw delta 1844 zcmXw34RBS(6~5>0-rc=>|MDC#gb2BLk_7S+5=z7l4oyN*NH8xytzb$}K{T~h1ELie zw1Hr;G1L-onN0>E6bET(6!1ar#Ezk9H4~)P3@TVK^)X~bn2s6(gp_C7eedC&Id|_l zyL-;v@7wQej)pdG&fbw8po*e&Nc-+dZO@w_hYen8+ zdJS7o1C#49c)R#m`OYl-+Y=zdhN^mU!dq}Z|WW@nHGPI`%2soz{=adW#IyfJ#(!KtH+*b}`8ILJW zED~7xwS(mEY>gfFW5+>rC!<#7VP#lx&N)5K4(D;_hmH&V;*Kb!H6Mjt_%ZylI@h|} z3Yh;k2heJBuer%wY8IHfF=8AyI*n(HYGamx;TQa0ycaFt9)FTA=9&6U{j`2WZ$qj2 zqi|8R^kTdaX6aMeHK<`jfY?FxC+t`3AvRw<$tKaS=pcQKK2PhFVfsC4k}uT#q<=2i zMV?hhNgW9hO>Neq+S}SrZH=}>%hD8l7JY&asV7k@O0Xi&l!d~oSlh~6f6;kp0nyZ> zxdbW_;m?m@SJ)RIPkcQF*<$4dXo?fw>jesriN+DwEzV8I_>Kv1MQj4H#QE#;H82iO ziTrW-wdOkHiZ`x85GQzi=bGliJh9}8oN63}kQXg*aiY*S4aoKRMHpwzWVfe}XUK znNUI2>oNSOep=1b59@7uBl#;jpqwVVC1TlPPZ`P<+ealPzps*CeJA3ujY4%2ga6fH zSB2?f?G?yQMut~XNkwh~p;c|v^`lq70a3M3Zk{Yr6?tVSSFE}URpPmL>WLB*OzzAV z*!9z{!Ez8S;qQX|z04K8*I}lpljSj+uVl8DPD^gc+@#Cmonl2f3W?%slqH@Ym&`s= zjdH|^GU-m~)Z~IKBDzqPEMBL%qTvS2#Lih|hCJIX8V{w(`*Pq0d?|WL(OOY1Wh~N5 zQC5=vx&_n~7pss+x=n^W{$Lob7jw3e3{@HM4i{wmeK+9_9KU4QGHvh$^(capX6Y_P z8x*#b6);XmXdmsQPt!2Xp(^=|_@tdYK^74gUDj@DpJ<1)R_zh3SWClW_#gNW&ROSZ z;wF?i)9f4eDV$<=+uQ6_Xpeo5J=yvOZLr?A_FJ2+Qd+Ym7?cPW~Z|IrYMq*=5{SkxCp19? z+^L*VzKsM5_o-rf4ZlTe^!_;3o_Mv2XN${as8-xl$%A5lSh|5qp6hp)qYshjs^IJV z2P*k5!H0YKE=Z#G5M?g>R9Io&Ax?&QtthSL!^x?0CnUa))$p)5SHrWCymq!TS5;nl zvAY`i-PO3hZpk98O<@7&EPaoxNdWV6QYl3;-JXa`L_0&yOPM@uFR)XsD+!4tfpMff zy3CE*k+?S4-ISV4Jh&I9huZis_oV&eptOwXU*+y@Rw^4JiD5=#Z i`O9jxAobkB!Q=_&Fx`D|dA%l$NTbr2zr0?5J?H-bdj=B#