KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS: - raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed - dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack - unchained: Cloudflare 403 → canonical Megaphone podcast feed - lynalden: Cloudflare 202 → FeedBurner mirror - glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1) - browser User-Agent + Accept headers on feed fetch - removed dead feeds with no active replacement: placeholder, dragonfly, niccarter, eugene - pin h2==4.3.0 (required by http2=True) All 25 remaining feeds verified fetching real body content; newest post per feed ≤88d. Bundles in-flight KOL-module work already in the working tree (kol_x ingest, migration 027, tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,14 @@
|
||||
API endpoints for the breakout signal monitor.
|
||||
|
||||
GET /api/signal/status — current state (enabled, recent signals)
|
||||
POST /api/signal/toggle — flip the on/off switch
|
||||
POST /api/signal/toggle — flip the on/off switch (requires X-Ingest-Key)
|
||||
GET /api/signal/history — last N signals (fired regardless of enabled state)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.services.funding_signal import (
|
||||
set_enabled,
|
||||
is_enabled,
|
||||
@@ -18,17 +20,29 @@ from app.services.funding_signal import (
|
||||
router = APIRouter(prefix="/signal", tags=["signal"])
|
||||
|
||||
|
||||
def _require_ingest_key(x_ingest_key: Optional[str]) -> None:
|
||||
"""Fail-closed operator-only guard (same pattern as signals.py)."""
|
||||
expected = settings.ingest_api_key
|
||||
if not expected:
|
||||
raise HTTPException(503, "toggle disabled (INGEST_API_KEY not configured)")
|
||||
if not x_ingest_key:
|
||||
raise HTTPException(401, "missing X-Ingest-Key header")
|
||||
if x_ingest_key != expected:
|
||||
raise HTTPException(401, "invalid X-Ingest-Key")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def status():
|
||||
return get_status()
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
async def toggle(enabled: bool):
|
||||
async def toggle(enabled: bool, x_ingest_key: Optional[str] = Header(default=None)):
|
||||
"""
|
||||
Body: ?enabled=true or ?enabled=false
|
||||
Example: POST /api/signal/toggle?enabled=true
|
||||
Operator-only toggle — requires X-Ingest-Key header (same secret as signal ingest).
|
||||
Example: POST /api/signal/toggle?enabled=true -H 'X-Ingest-Key: …'
|
||||
"""
|
||||
_require_ingest_key(x_ingest_key)
|
||||
set_enabled(enabled)
|
||||
return {"enabled": is_enabled()}
|
||||
|
||||
|
||||
+52
-10
@@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -59,6 +59,11 @@ def _summary_dto(post: KolPost) -> dict:
|
||||
"tickers": _parse_tickers(post.tickers_json),
|
||||
"analyzed_at": iso_utc(post.analyzed_at),
|
||||
"analysis_model": post.analysis_model,
|
||||
# Extended x_analysis fields (migration 027)
|
||||
"tier": post.tier,
|
||||
"post_type": post.post_type,
|
||||
"talks_vs_trades_flag": post.talks_vs_trades_flag or False,
|
||||
"sentiment": post.sentiment,
|
||||
}
|
||||
|
||||
|
||||
@@ -71,19 +76,47 @@ def _detail_dto(post: KolPost) -> dict:
|
||||
@router.get("/kol/posts")
|
||||
async def list_kol_posts(
|
||||
handle: Optional[str] = Query(default=None, description="filter by kol_handle"),
|
||||
source: Optional[str] = Query(default=None, description="substack | twitter"),
|
||||
source: Optional[str] = Query(default=None, description="substack | blog | podcast"),
|
||||
signals_only: bool = Query(default=False,
|
||||
description="exclude noise posts (tier='noise')"),
|
||||
ticker: Optional[str] = Query(default=None, description="filter by ticker symbol e.g. BTC"),
|
||||
days: Optional[int] = Query(default=None, ge=1, le=365, description="restrict to last N days"),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
page: int = Query(default=1, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(KolPost)
|
||||
base = select(KolPost)
|
||||
if handle:
|
||||
stmt = stmt.where(KolPost.kol_handle == handle)
|
||||
base = base.where(KolPost.kol_handle == handle)
|
||||
if source:
|
||||
stmt = stmt.where(KolPost.source == source)
|
||||
stmt = stmt.order_by(KolPost.published_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return {"items": [_summary_dto(p) for p in rows], "page": page, "limit": limit}
|
||||
base = base.where(KolPost.source == source)
|
||||
if signals_only:
|
||||
base = base.where(
|
||||
(KolPost.tier.is_(None)) | (KolPost.tier != "noise")
|
||||
)
|
||||
if ticker:
|
||||
# tickers_json stores [{"ticker":"BTC",...}] — match the key-value pair
|
||||
base = base.where(KolPost.tickers_json.like(f'%"ticker": "{ticker.upper()}"%'))
|
||||
if days:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
base = base.where(KolPost.published_at >= cutoff)
|
||||
|
||||
total: int = (await db.execute(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
)).scalar_one()
|
||||
|
||||
rows = (await db.execute(
|
||||
base.order_by(KolPost.published_at.desc())
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
)).scalars().all()
|
||||
|
||||
return {
|
||||
"items": [_summary_dto(p) for p in rows],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kol/posts/{post_id}")
|
||||
@@ -365,14 +398,23 @@ async def list_divergence(
|
||||
signal_type=alignment → KOL's words matched their on-chain action (reinforced signal).
|
||||
"""
|
||||
since = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=days)
|
||||
stmt = select(KolDivergence).where(KolDivergence.created_at >= since)
|
||||
# Use post_at (when the KOL post was published) rather than created_at
|
||||
# (when the divergence row was written to DB). created_at can lag by
|
||||
# days or weeks when bulk scans are run, causing old event-pairs to
|
||||
# appear as "recent" divergences long after they occurred.
|
||||
stmt = select(KolDivergence).where(KolDivergence.post_at >= since)
|
||||
if handle:
|
||||
stmt = stmt.where(KolDivergence.handle == handle)
|
||||
if ticker:
|
||||
stmt = stmt.where(KolDivergence.ticker == ticker.upper())
|
||||
if signal_type:
|
||||
stmt = stmt.where(KolDivergence.signal_type == signal_type)
|
||||
stmt = stmt.order_by(KolDivergence.created_at.desc()).limit(200)
|
||||
# Order by post_at (when the KOL actually published), consistent with the
|
||||
# post_at window filter above. Ordering by created_at (DB write time) put
|
||||
# a backfilled older post (post_at=05-18, created_at=05-28) ahead of a
|
||||
# newer one (post_at=05-23), so the "latest divergence" read wrong.
|
||||
# Tie-break on created_at so same-day posts have a stable order.
|
||||
stmt = stmt.order_by(KolDivergence.post_at.desc(), KolDivergence.created_at.desc()).limit(200)
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return {
|
||||
"window_days": days,
|
||||
|
||||
+20
-5
@@ -76,14 +76,29 @@ async def get_performance(
|
||||
max_drawdown_pct=0.0,
|
||||
)
|
||||
|
||||
winning = sum(1 for t in trades if (t.pnl_usd or 0) > 0)
|
||||
win_rate = winning / total_trades
|
||||
# Only include trades with a known PnL in financial statistics.
|
||||
# Trades with pnl_usd=NULL were externally closed or unsettled — treating
|
||||
# them as 0 silently inflates trade count and distorts win rate / net PnL.
|
||||
settled = [t for t in trades if t.pnl_usd is not None]
|
||||
if not settled:
|
||||
return BotPerformance(
|
||||
period_days=PERIOD_DAYS,
|
||||
total_trades=total_trades,
|
||||
win_rate=0.0,
|
||||
net_pnl_usd=0.0,
|
||||
avg_hold_seconds=0.0,
|
||||
max_drawdown_pct=0.0,
|
||||
)
|
||||
|
||||
pnl_values = [(t.pnl_usd or 0.0) for t in trades]
|
||||
winning = sum(1 for t in settled if t.pnl_usd > 0) # type: ignore[operator]
|
||||
win_rate = winning / len(settled)
|
||||
|
||||
pnl_values = [t.pnl_usd for t in settled] # type: ignore[misc]
|
||||
net_pnl = sum(pnl_values)
|
||||
|
||||
hold_values = [(t.hold_seconds or 0) for t in trades]
|
||||
avg_hold = sum(hold_values) / len(hold_values)
|
||||
# For hold time use all trades (we always have opened_at + closed_at when closed)
|
||||
hold_values = [t.hold_seconds for t in trades if t.hold_seconds is not None]
|
||||
avg_hold = sum(hold_values) / len(hold_values) if hold_values else 0.0
|
||||
|
||||
# Max drawdown: running peak → trough of cumulative PnL
|
||||
cumulative = 0.0
|
||||
|
||||
+41
-17
@@ -199,11 +199,15 @@ async def get_today_stats(
|
||||
hour=0, minute=0, second=0, microsecond=0, tzinfo=None
|
||||
)
|
||||
|
||||
# Exclude released trades — these were released back to user control and
|
||||
# closed by the user on HL directly, not by the bot. Including them would
|
||||
# inflate the bot's "today" P&L with trades the bot didn't manage.
|
||||
closed_rows = await db.execute(
|
||||
select(BotTrade).where(
|
||||
BotTrade.wallet_address == wallet,
|
||||
BotTrade.closed_at >= midnight,
|
||||
BotTrade.pnl_usd.is_not(None),
|
||||
BotTrade.released_at.is_(None),
|
||||
)
|
||||
)
|
||||
closed = closed_rows.scalars().all()
|
||||
@@ -280,6 +284,15 @@ async def manual_close(
|
||||
if not wallet or not isinstance(timestamp, int) or not isinstance(signature, str):
|
||||
raise HTTPException(422, "wallet, timestamp, signature required")
|
||||
|
||||
# Verify signature first — prevents trade_id enumeration via 403/409 before auth.
|
||||
verify_signed_request(
|
||||
action=ACTION_CLOSE_TRADE,
|
||||
wallet=wallet,
|
||||
timestamp_ms=timestamp,
|
||||
signature=signature,
|
||||
body={"trade_id": trade_id},
|
||||
)
|
||||
|
||||
# Load the trade. Must be open AND owned by the signing wallet.
|
||||
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
|
||||
if trade is None:
|
||||
@@ -289,14 +302,6 @@ async def manual_close(
|
||||
if trade.closed_at is not None:
|
||||
raise HTTPException(409, f"trade {trade_id} is already closed")
|
||||
|
||||
verify_signed_request(
|
||||
action=ACTION_CLOSE_TRADE,
|
||||
wallet=wallet,
|
||||
timestamp_ms=timestamp,
|
||||
signature=signature,
|
||||
body={"trade_id": trade_id},
|
||||
)
|
||||
|
||||
# Find the API key from the subscription.
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.wallet_address == wallet)
|
||||
@@ -330,7 +335,26 @@ async def manual_close(
|
||||
force=True,
|
||||
)
|
||||
|
||||
closed = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one()
|
||||
# B45: close_and_finalize uses its own AsyncSessionLocal session, so the
|
||||
# route's `db` session has a stale identity-map cache for this trade row.
|
||||
# `populate_existing=True` forces SQLAlchemy to overwrite the cached
|
||||
# instance with the freshly-committed values (exit_price, pnl_usd, etc.)
|
||||
# rather than returning the pre-close snapshot from the identity map.
|
||||
closed = (await db.execute(
|
||||
select(BotTrade).where(BotTrade.id == trade_id).execution_options(populate_existing=True)
|
||||
)).scalar_one()
|
||||
|
||||
# B46: close_and_finalize returns silently on certain failures (no price
|
||||
# for paper close, HL returns no fill, etc.) without raising an exception.
|
||||
# Detect the failure by checking whether closed_at was actually written.
|
||||
if closed.closed_at is None:
|
||||
raise HTTPException(
|
||||
500,
|
||||
"Close command issued but the position could not be closed "
|
||||
"(no price feed, HL fill failure, or another caller closed it first). "
|
||||
"Refresh open positions — if it still shows, retry or close manually on HL."
|
||||
)
|
||||
|
||||
return CloseTradeResponse(
|
||||
status="ok",
|
||||
trade_id=trade_id,
|
||||
@@ -373,14 +397,6 @@ async def set_trade_grow(
|
||||
if body_tid != trade_id:
|
||||
raise HTTPException(400, "trade_id mismatch (path vs signed body)")
|
||||
|
||||
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
|
||||
if trade is None:
|
||||
raise HTTPException(404, f"trade {trade_id} not found")
|
||||
if trade.wallet_address.lower() != wallet:
|
||||
raise HTTPException(403, "trade belongs to a different wallet")
|
||||
if trade.closed_at is not None:
|
||||
raise HTTPException(409, f"trade {trade_id} is already closed")
|
||||
|
||||
verify_signed_request(
|
||||
action=ACTION_SET_GROW,
|
||||
wallet=wallet,
|
||||
@@ -389,6 +405,14 @@ async def set_trade_grow(
|
||||
body={"trade_id": trade_id, "enabled": enabled},
|
||||
)
|
||||
|
||||
trade = (await db.execute(select(BotTrade).where(BotTrade.id == trade_id))).scalar_one_or_none()
|
||||
if trade is None:
|
||||
raise HTTPException(404, f"trade {trade_id} not found")
|
||||
if trade.wallet_address.lower() != wallet:
|
||||
raise HTTPException(403, "trade belongs to a different wallet")
|
||||
if trade.closed_at is not None:
|
||||
raise HTTPException(409, f"trade {trade_id} is already closed")
|
||||
|
||||
trade.grow_mode = enabled
|
||||
await db.commit()
|
||||
|
||||
|
||||
+167
-4
@@ -5,16 +5,29 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.ratelimit import limiter
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Post, iso_utc
|
||||
from app.schemas import PriceImpact, TrumpPost
|
||||
from app.schemas import PostFilterCounts, PostListResponse, PriceImpact, SourceCount, TrumpPost
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ARCHIVE_EXCLUDED_SOURCES = (
|
||||
"truth",
|
||||
"btc_bottom_reversal",
|
||||
"funding_reversal",
|
||||
"kol_divergence",
|
||||
)
|
||||
|
||||
|
||||
_AI_SCORED_EXPR = (
|
||||
(func.coalesce(Post.ai_confidence, 0) > 0) |
|
||||
Post.ai_reasoning.is_not(None)
|
||||
)
|
||||
|
||||
|
||||
def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[bool]:
|
||||
if pct is None or signal is None:
|
||||
@@ -98,11 +111,161 @@ async def get_posts(
|
||||
return [_post_to_schema(p) for p in posts]
|
||||
|
||||
|
||||
@router.get("/posts-paged", response_model=PostListResponse)
|
||||
@limiter.limit("60/minute")
|
||||
async def get_posts_page(
|
||||
request: Request,
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
page: int = Query(default=1, ge=1),
|
||||
source: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Filter to a single source (e.g. 'truth', 'btc_bottom_reversal').",
|
||||
),
|
||||
source_in: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Comma-separated allowlist of sources.",
|
||||
),
|
||||
source_not_in: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Comma-separated denylist of sources.",
|
||||
),
|
||||
archive_only: bool = Query(
|
||||
default=False,
|
||||
description="When true, return only archived/retired sources (exclude live modules).",
|
||||
),
|
||||
sentiment: Optional[str] = Query(
|
||||
default=None,
|
||||
pattern="^(bullish|bearish|neutral)$",
|
||||
description="Optional sentiment filter.",
|
||||
),
|
||||
signal: Optional[str] = Query(
|
||||
default=None,
|
||||
pattern="^(buy|short|actionable)$",
|
||||
description="Optional signal filter. 'actionable' = buy or short.",
|
||||
),
|
||||
ai_scored_only: bool = Query(
|
||||
default=False,
|
||||
description="When true, exclude off-topic rows that were skipped before AI scoring.",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
response: Response = None,
|
||||
):
|
||||
offset = (page - 1) * limit
|
||||
stmt = select(Post)
|
||||
count_stmt = select(func.count()).select_from(Post)
|
||||
counts_stmt = select(
|
||||
func.count().label("all_count"),
|
||||
func.sum(case((Post.signal.in_(("buy", "short")), 1), else_=0)).label("actionable_count"),
|
||||
func.sum(case((Post.signal == "buy", 1), else_=0)).label("buy_count"),
|
||||
func.sum(case((Post.signal == "short", 1), else_=0)).label("short_count"),
|
||||
func.sum(case((_AI_SCORED_EXPR, 0), else_=1)).label("off_topic_count"),
|
||||
).select_from(Post)
|
||||
source_counts_stmt = select(
|
||||
Post.source.label("source"),
|
||||
func.count(Post.id).label("count"),
|
||||
func.max(Post.published_at).label("latest"),
|
||||
).select_from(Post)
|
||||
|
||||
included_sources = [s.strip() for s in (source_in or "").split(",") if s.strip()]
|
||||
excluded_sources = [s.strip() for s in (source_not_in or "").split(",") if s.strip()]
|
||||
if archive_only:
|
||||
excluded_sources = list(dict.fromkeys([*excluded_sources, *_ARCHIVE_EXCLUDED_SOURCES]))
|
||||
|
||||
if source:
|
||||
stmt = stmt.where(Post.source == source)
|
||||
count_stmt = count_stmt.where(Post.source == source)
|
||||
counts_stmt = counts_stmt.where(Post.source == source)
|
||||
source_counts_stmt = source_counts_stmt.where(Post.source == source)
|
||||
elif included_sources:
|
||||
stmt = stmt.where(Post.source.in_(included_sources))
|
||||
count_stmt = count_stmt.where(Post.source.in_(included_sources))
|
||||
counts_stmt = counts_stmt.where(Post.source.in_(included_sources))
|
||||
# NOTE: source_counts is the chip/source breakdown the UI renders the
|
||||
# filter bar from. It must reflect every source available in the
|
||||
# current view scope — NOT just the one the user has selected. So
|
||||
# `source_in` (the chip-selection narrowing) is deliberately NOT
|
||||
# applied here; only the exclusion filters (archive_only /
|
||||
# source_not_in) below scope it. Applying it would collapse the chip
|
||||
# bar to the single selected source with no way back to "all".
|
||||
|
||||
if excluded_sources:
|
||||
stmt = stmt.where(~Post.source.in_(excluded_sources))
|
||||
count_stmt = count_stmt.where(~Post.source.in_(excluded_sources))
|
||||
counts_stmt = counts_stmt.where(~Post.source.in_(excluded_sources))
|
||||
source_counts_stmt = source_counts_stmt.where(~Post.source.in_(excluded_sources))
|
||||
|
||||
if sentiment:
|
||||
stmt = stmt.where(Post.sentiment == sentiment)
|
||||
count_stmt = count_stmt.where(Post.sentiment == sentiment)
|
||||
counts_stmt = counts_stmt.where(Post.sentiment == sentiment)
|
||||
source_counts_stmt = source_counts_stmt.where(Post.sentiment == sentiment)
|
||||
if ai_scored_only:
|
||||
stmt = stmt.where(_AI_SCORED_EXPR)
|
||||
count_stmt = count_stmt.where(_AI_SCORED_EXPR)
|
||||
source_counts_stmt = source_counts_stmt.where(_AI_SCORED_EXPR)
|
||||
|
||||
if signal == "actionable":
|
||||
stmt = stmt.where(Post.signal.in_(("buy", "short")))
|
||||
count_stmt = count_stmt.where(Post.signal.in_(("buy", "short")))
|
||||
elif signal:
|
||||
stmt = stmt.where(Post.signal == signal)
|
||||
count_stmt = count_stmt.where(Post.signal == signal)
|
||||
|
||||
stmt = stmt.order_by(Post.published_at.desc()).offset(offset).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
total_result = await db.execute(count_stmt)
|
||||
counts_result = await db.execute(counts_stmt)
|
||||
source_counts_result = await db.execute(
|
||||
source_counts_stmt.group_by(Post.source).order_by(func.count(Post.id).desc(), Post.source.asc())
|
||||
)
|
||||
posts = result.scalars().all()
|
||||
total = int(total_result.scalar_one() or 0)
|
||||
counts_row = counts_result.one()
|
||||
off_topic = int(counts_row.off_topic_count or 0)
|
||||
all_count = int(counts_row.all_count or 0)
|
||||
if response is not None:
|
||||
response.headers["Cache-Control"] = "public, max-age=30, stale-while-revalidate=60"
|
||||
return PostListResponse(
|
||||
items=[_post_to_schema(p) for p in posts],
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit,
|
||||
counts=PostFilterCounts(
|
||||
all=max(0, all_count - off_topic) if ai_scored_only else all_count,
|
||||
actionable=int(counts_row.actionable_count or 0),
|
||||
buy=int(counts_row.buy_count or 0),
|
||||
short=int(counts_row.short_count or 0),
|
||||
off_topic=off_topic,
|
||||
),
|
||||
source_counts=[
|
||||
SourceCount(
|
||||
source=row.source,
|
||||
count=int(row.count or 0),
|
||||
latest=iso_utc(row.latest),
|
||||
)
|
||||
for row in source_counts_result.all()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/signals/accuracy")
|
||||
async def signal_accuracy(db: AsyncSession = Depends(get_db)):
|
||||
"""Aggregate accuracy of directional signals (buy/sell/short) against realised price moves."""
|
||||
"""Aggregate accuracy of directional signals against realised price moves.
|
||||
|
||||
Scoped to the CURRENT signal taxonomy the live bot actually trades:
|
||||
* only buy/short (the retired "sell" vocabulary is excluded — the bot
|
||||
emits buy/short now, and 42 legacy truth/sell rows would otherwise
|
||||
pollute the public scoreboard),
|
||||
* only production sources (SUPPORTED_TRADING_SOURCES) — retired/test
|
||||
ingest sources like rsi_reversal, sma_reclaim, breakout, phase1 and
|
||||
`test` must not appear in the public accuracy stats.
|
||||
"""
|
||||
from app.services.signal_categories import SUPPORTED_TRADING_SOURCES
|
||||
result = await db.execute(
|
||||
select(Post).where(Post.signal.in_(["buy", "sell", "short"]))
|
||||
select(Post).where(
|
||||
Post.signal.in_(["buy", "short"]),
|
||||
func.lower(Post.source).in_(SUPPORTED_TRADING_SOURCES),
|
||||
)
|
||||
)
|
||||
posts = result.scalars().all()
|
||||
|
||||
|
||||
+17
-2
@@ -31,6 +31,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -174,8 +175,22 @@ async def ingest_signal(
|
||||
prefilter_reason="external_signal", # bypasses entry-filter audit
|
||||
)
|
||||
db.add(post)
|
||||
await db.commit()
|
||||
await db.refresh(post)
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(post)
|
||||
except IntegrityError:
|
||||
# Concurrent request beat us to the INSERT — fetch the existing row.
|
||||
await db.rollback()
|
||||
existing2 = await db.execute(select(Post).where(Post.external_id == ext_id_hashed))
|
||||
prior2 = existing2.scalar_one_or_none()
|
||||
if prior2:
|
||||
return SignalIngestResponse(
|
||||
status="duplicate",
|
||||
post_id=prior2.id,
|
||||
dedup_against=prior2.id,
|
||||
note=f"concurrent ingest: external_id {body.external_id!r} already exists",
|
||||
)
|
||||
raise # unexpected — re-raise if we still can't find the row
|
||||
|
||||
logger.info(
|
||||
"Ingested signal: source=%s id=%s → post_id=%d, %s/%s conf=%d category=%s",
|
||||
|
||||
+68
-7
@@ -87,14 +87,21 @@ class InitResponse(BaseModel):
|
||||
expires_in_seconds: int
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{wallet}/status", response_model=StatusResponse)
|
||||
async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusResponse:
|
||||
"""Public-by-wallet read. Returns whether server is configured AND
|
||||
whether this wallet has bound a Telegram chat."""
|
||||
wallet = wallet.lower().strip()
|
||||
async def _build_status(
|
||||
wallet: str,
|
||||
db: AsyncSession,
|
||||
authenticated: bool = False,
|
||||
) -> StatusResponse:
|
||||
"""Shared status-building logic used by both the GET endpoint and
|
||||
update_preferences so both always return a consistent StatusResponse.
|
||||
|
||||
B37: previously update_preferences called `await status(wallet, db)`,
|
||||
which passed db as the `timestamp` positional arg and left the DI-managed
|
||||
`db` param as a raw Depends() wrapper — crashing on `.execute()`.
|
||||
"""
|
||||
configured = bool(settings.telegram_bot_token and settings.telegram_bot_username)
|
||||
b = (await db.execute(
|
||||
select(TelegramBinding).where(TelegramBinding.wallet_address == wallet)
|
||||
@@ -104,6 +111,14 @@ async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusRespo
|
||||
return StatusResponse(configured=configured,
|
||||
bot_username=settings.telegram_bot_username or None,
|
||||
bound=False)
|
||||
|
||||
if not authenticated:
|
||||
return StatusResponse(
|
||||
configured=configured,
|
||||
bot_username=settings.telegram_bot_username or None,
|
||||
bound=True,
|
||||
)
|
||||
|
||||
return StatusResponse(
|
||||
configured=configured,
|
||||
bot_username=settings.telegram_bot_username or None,
|
||||
@@ -123,6 +138,49 @@ async def status(wallet: str, db: AsyncSession = Depends(get_db)) -> StatusRespo
|
||||
)
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{wallet}/status", response_model=StatusResponse)
|
||||
async def status(
|
||||
wallet: str,
|
||||
timestamp: Optional[int] = None,
|
||||
signature: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> StatusResponse:
|
||||
"""Wallet Telegram status.
|
||||
|
||||
Unauthenticated: returns configured + bound (boolean only).
|
||||
Authenticated (timestamp + signature): returns full binding details.
|
||||
This prevents third parties from de-anonymising wallets via tg_username/chat_id.
|
||||
"""
|
||||
wallet = wallet.lower().strip()
|
||||
|
||||
# Verify ownership if credentials provided (best-effort — never block on failure).
|
||||
# Accept either "view_telegram_status" (dedicated action) or "view_user"
|
||||
# (broad read action that the frontend already caches for other endpoints).
|
||||
# Accepting view_user avoids requiring a separate wallet signature just to
|
||||
# see Telegram status — the frontend reuses its cached view_user envelope.
|
||||
authenticated = False
|
||||
if timestamp is not None and signature:
|
||||
for _action in ("view_telegram_status", "view_user"):
|
||||
try:
|
||||
verify_signed_request(
|
||||
action=_action,
|
||||
wallet=wallet,
|
||||
timestamp_ms=timestamp,
|
||||
signature=signature,
|
||||
body=None,
|
||||
allow_replay=True,
|
||||
)
|
||||
authenticated = True
|
||||
break
|
||||
except Exception:
|
||||
pass # Try next action; fall through to redacted response if all fail
|
||||
|
||||
return await _build_status(wallet, db, authenticated=authenticated)
|
||||
|
||||
|
||||
@router.post("/{wallet}/init", response_model=InitResponse)
|
||||
async def init_binding(
|
||||
wallet: str, body: SignedEnvelope,
|
||||
@@ -192,7 +250,10 @@ async def update_preferences(
|
||||
await db.commit()
|
||||
await db.refresh(b)
|
||||
|
||||
return await status(wallet, db)
|
||||
# Return full authenticated status — the caller already proved ownership
|
||||
# via the signed request verified above (B37: was `await status(wallet, db)`
|
||||
# which passed db as the timestamp arg, crashing inside status()).
|
||||
return await _build_status(wallet, db, authenticated=True)
|
||||
|
||||
|
||||
@router.post("/{wallet}/unbind")
|
||||
|
||||
+20
-13
@@ -19,27 +19,34 @@ ACTION_VIEW_USER = "view_user"
|
||||
|
||||
|
||||
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
|
||||
# Join against trigger_post to surface the source tag. When a trade was
|
||||
# opened by an ingested signal (VCP scanner, user's module, etc.) the
|
||||
# source reveals WHICH module produced it — critical for "is module X
|
||||
# actually making money?" attribution analysis.
|
||||
trigger_source = None
|
||||
trigger_post_text = None
|
||||
if trade.trigger_post is not None:
|
||||
trigger_source = trade.trigger_post.source
|
||||
# Paper trades are tagged via hl_order_id at open time; that's the only
|
||||
# stable signal we have to distinguish them in aggregate views.
|
||||
trigger_post_text = trade.trigger_post.text
|
||||
elif trade.hl_order_id and str(trade.hl_order_id).startswith("adopted:"):
|
||||
# Adopted (sys2 manage-only) trades have trigger_post_id=NULL by
|
||||
# construction, so trigger_source would otherwise fall through to
|
||||
# "Unknown" in the UI. The hl_order_id prefix is the reliable marker
|
||||
# (see adoption.py / CLAUDE.md) — surface it as a proper attribution.
|
||||
trigger_source = "adopted"
|
||||
is_paper = (trade.hl_order_id == "paper")
|
||||
# Preserve None for nullable fields — do NOT coerce to 0 / "".
|
||||
# The frontend uses null to distinguish "unknown/not yet settled" from
|
||||
# a genuine zero (e.g. a break-even trade, a 0-second hold).
|
||||
# `or 0` was silently masking externally-closed trades and unsettled PnL.
|
||||
return BotTradeSchema(
|
||||
id=trade.id,
|
||||
asset=trade.asset,
|
||||
side=trade.side,
|
||||
entry_price=trade.entry_price,
|
||||
exit_price=trade.exit_price or 0.0,
|
||||
pnl_usd=trade.pnl_usd or 0.0,
|
||||
hold_seconds=trade.hold_seconds or 0,
|
||||
trigger_post_id=trade.trigger_post_id or 0,
|
||||
exit_price=trade.exit_price, # None = position still open or extern-closed
|
||||
pnl_usd=trade.pnl_usd, # None = unsettled / extern-closed
|
||||
hold_seconds=trade.hold_seconds, # None = not yet computed
|
||||
trigger_post_id=trade.trigger_post_id, # None = adopted/manual, no trigger post
|
||||
opened_at=iso_utc(trade.opened_at) or "",
|
||||
closed_at=iso_utc(trade.closed_at) or "",
|
||||
closed_at=iso_utc(trade.closed_at), # None = still open (shouldn't happen here)
|
||||
trigger_post_text=trigger_post_text,
|
||||
trigger_source=trigger_source,
|
||||
is_paper=is_paper,
|
||||
)
|
||||
@@ -50,7 +57,7 @@ async def get_trades(
|
||||
wallet: str = Query(..., description="Wallet address (lower-cased internally)"),
|
||||
ts: int = Query(..., description="Signed timestamp (ms)"),
|
||||
sig: str = Query(..., description="EIP-191 signature"),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
limit: int = Query(default=20, ge=1, le=500), # raised from 100: Analytics needs 500 for full history
|
||||
page: int = Query(default=1, ge=1),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -71,7 +78,7 @@ async def get_trades(
|
||||
.options(joinedload(BotTrade.trigger_post))
|
||||
.where(BotTrade.wallet_address == wallet)
|
||||
.where(BotTrade.closed_at.is_not(None))
|
||||
.order_by(BotTrade.opened_at.desc())
|
||||
.order_by(BotTrade.closed_at.desc()) # closed_at = settlement time; opened_at would bury recently-closed old trades
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
+14
-6
@@ -42,17 +42,18 @@ async def verify_hl_api_key_can_trade(api_key: str, account_address: str) -> Non
|
||||
|
||||
|
||||
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
|
||||
# Preserve None — do NOT coerce to 0 / "". See trades.py comment.
|
||||
return BotTradeSchema(
|
||||
id=trade.id,
|
||||
asset=trade.asset,
|
||||
side=trade.side,
|
||||
entry_price=trade.entry_price,
|
||||
exit_price=trade.exit_price or 0.0,
|
||||
pnl_usd=trade.pnl_usd or 0.0,
|
||||
hold_seconds=trade.hold_seconds or 0,
|
||||
trigger_post_id=trade.trigger_post_id or 0,
|
||||
exit_price=trade.exit_price,
|
||||
pnl_usd=trade.pnl_usd,
|
||||
hold_seconds=trade.hold_seconds,
|
||||
trigger_post_id=trade.trigger_post_id,
|
||||
opened_at=iso_utc(trade.opened_at) or "",
|
||||
closed_at=iso_utc(trade.closed_at) or "",
|
||||
closed_at=iso_utc(trade.closed_at),
|
||||
)
|
||||
|
||||
|
||||
@@ -107,7 +108,7 @@ async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
|
||||
"wallet_address": wallet, "active": False, "hl_api_key_set": False,
|
||||
"paper_mode": False, "manual_window_until": None,
|
||||
"circuit_breaker_tripped_at": None, "circuit_breaker_reason": None,
|
||||
"auto_trade": False,
|
||||
"auto_trade": False, "trump_enabled": False, "macro_enabled": False,
|
||||
}
|
||||
return {
|
||||
"wallet_address": wallet,
|
||||
@@ -119,6 +120,12 @@ async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
|
||||
"circuit_breaker_tripped_at": iso_utc(sub.circuit_breaker_tripped_at),
|
||||
"circuit_breaker_reason": sub.circuit_breaker_reason,
|
||||
"auto_trade": bool(sub.auto_trade),
|
||||
# Per-system enable flags. bot_engine gates System-1 on trump_enabled
|
||||
# and System-2 on macro_enabled (see _execute_for_subscriber). Without
|
||||
# these, the UI claims "next Trump signal auto-opens" even when
|
||||
# trump_enabled=0, where the backend actually skips the trade.
|
||||
"trump_enabled": bool(sub.trump_enabled),
|
||||
"macro_enabled": bool(sub.macro_enabled),
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +186,7 @@ async def get_user(
|
||||
hl_api_key_set=hl_api_key_set,
|
||||
hl_api_key_masked=masked,
|
||||
paper_mode=bool(sub.paper_mode),
|
||||
auto_trade=bool(sub.auto_trade),
|
||||
trades=[_trade_to_schema(t) for t in trades],
|
||||
settings=UserSettings(
|
||||
leverage=sub.leverage,
|
||||
|
||||
Reference in New Issue
Block a user