done
This commit is contained in:
+2
-2
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db, AsyncSessionLocal
|
||||
from app.models import Post
|
||||
from app.models import Post, iso_utc
|
||||
from app.ws.manager import manager
|
||||
import hashlib, time
|
||||
|
||||
@@ -45,7 +45,7 @@ async def insert_fake_post(
|
||||
"id": post.id,
|
||||
"text": post.text,
|
||||
"source": post.source,
|
||||
"published_at": post.published_at.isoformat(),
|
||||
"published_at": iso_utc(post.published_at),
|
||||
"sentiment": post.sentiment,
|
||||
"ai_confidence": post.ai_confidence,
|
||||
"relevant": post.relevant,
|
||||
|
||||
+63
-6
@@ -6,35 +6,47 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Post
|
||||
from app.models import Post, iso_utc
|
||||
from app.schemas import PriceImpact, TrumpPost
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _direction_correct(signal: Optional[str], pct: Optional[float]) -> Optional[bool]:
|
||||
if pct is None or signal is None:
|
||||
return None
|
||||
if signal == "buy":
|
||||
return pct > 0
|
||||
if signal in ("short", "sell"):
|
||||
return pct < 0
|
||||
return None # hold has no direction
|
||||
|
||||
|
||||
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
|
||||
):
|
||||
if post.price_impact_asset and post.price_at_post is not None:
|
||||
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,
|
||||
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),
|
||||
)
|
||||
return TrumpPost(
|
||||
id=post.id,
|
||||
text=post.text,
|
||||
source=post.source,
|
||||
published_at=post.published_at.isoformat(),
|
||||
published_at=iso_utc(post.published_at),
|
||||
sentiment=post.sentiment,
|
||||
signal=post.signal,
|
||||
ai_confidence=post.ai_confidence,
|
||||
ai_reasoning=post.ai_reasoning,
|
||||
prefilter_reason=post.prefilter_reason,
|
||||
analysis_version=post.analysis_version,
|
||||
relevant=post.relevant,
|
||||
price_impact=price_impact,
|
||||
)
|
||||
@@ -54,6 +66,51 @@ async def get_posts(
|
||||
return [_post_to_schema(p) for p in posts]
|
||||
|
||||
|
||||
@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."""
|
||||
result = await db.execute(
|
||||
select(Post).where(Post.signal.in_(["buy", "sell", "short"]))
|
||||
)
|
||||
posts = result.scalars().all()
|
||||
|
||||
def bucket():
|
||||
return {"checked": 0, "correct": 0}
|
||||
|
||||
stats = {"m5": bucket(), "m15": bucket(), "m1h": bucket()}
|
||||
by_signal: dict[str, dict] = {}
|
||||
|
||||
for p in posts:
|
||||
sig = p.signal
|
||||
if sig not in by_signal:
|
||||
by_signal[sig] = {"m5": bucket(), "m15": bucket(), "m1h": bucket(), "count": 0}
|
||||
by_signal[sig]["count"] += 1
|
||||
for win, val in (("m5", p.price_impact_m5), ("m15", p.price_impact_m15), ("m1h", p.price_impact_m1h)):
|
||||
ok = _direction_correct(sig, val)
|
||||
if ok is None:
|
||||
continue
|
||||
stats[win]["checked"] += 1
|
||||
stats[win]["correct"] += int(ok)
|
||||
by_signal[sig][win]["checked"] += 1
|
||||
by_signal[sig][win]["correct"] += int(ok)
|
||||
|
||||
def pct(b): return round(b["correct"] / b["checked"] * 100, 1) if b["checked"] else None
|
||||
|
||||
return {
|
||||
"overall": {k: {**v, "accuracy_pct": pct(v)} for k, v in stats.items()},
|
||||
"by_signal": {
|
||||
s: {
|
||||
"count": d["count"],
|
||||
"m5": {**d["m5"], "accuracy_pct": pct(d["m5"])},
|
||||
"m15": {**d["m15"], "accuracy_pct": pct(d["m15"])},
|
||||
"m1h": {**d["m1h"], "accuracy_pct": pct(d["m1h"])},
|
||||
}
|
||||
for s, d in by_signal.items()
|
||||
},
|
||||
"total_directional_signals": len(posts),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", response_model=TrumpPost)
|
||||
async def get_post(post_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(Post).where(Post.id == post_id))
|
||||
|
||||
+11
-30
@@ -1,48 +1,33 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from eth_account import Account
|
||||
from eth_account.messages import encode_defunct
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Subscription
|
||||
from app.schemas import SubscribeRequest, SubscribeResponse
|
||||
from app.services.signed_request import verify_signed_request
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SIGN_MESSAGE = "Sign in to TrumpSignal"
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
def _recover_signer(signature: str) -> Optional[str]:
|
||||
"""Recover signer address from an EIP-191 personal_sign signature."""
|
||||
try:
|
||||
message = encode_defunct(text=SIGN_MESSAGE)
|
||||
recovered = Account.recover_message(message, signature=signature)
|
||||
return recovered.lower()
|
||||
except Exception as exc:
|
||||
logger.warning("Signature recovery failed: %s", exc)
|
||||
return None
|
||||
ACTION_SUBSCRIBE = "subscribe"
|
||||
|
||||
|
||||
@router.post("/subscribe", response_model=SubscribeResponse)
|
||||
async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
|
||||
wallet = body.wallet.lower().strip()
|
||||
|
||||
# Verify EIP-191 signature
|
||||
recovered = _recover_signer(body.signature)
|
||||
if recovered is None or recovered != wallet:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Signature verification failed: recovered address does not match wallet",
|
||||
)
|
||||
verify_signed_request(
|
||||
action=ACTION_SUBSCRIBE,
|
||||
wallet=wallet,
|
||||
timestamp_ms=body.timestamp,
|
||||
signature=body.signature,
|
||||
body=None,
|
||||
)
|
||||
|
||||
# Upsert subscription
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.wallet_address == wallet)
|
||||
)
|
||||
@@ -50,11 +35,7 @@ async def subscribe(body: SubscribeRequest, db: AsyncSession = Depends(get_db)):
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if sub is None:
|
||||
sub = Subscription(
|
||||
wallet_address=wallet,
|
||||
active=True,
|
||||
subscribed_at=now,
|
||||
)
|
||||
sub = Subscription(wallet_address=wallet, active=True, subscribed_at=now)
|
||||
db.add(sub)
|
||||
else:
|
||||
sub.active = True
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import BotTrade
|
||||
from app.models import BotTrade, iso_utc
|
||||
from app.schemas import BotTrade as BotTradeSchema
|
||||
|
||||
router = APIRouter()
|
||||
@@ -23,8 +23,8 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
|
||||
pnl_usd=trade.pnl_usd or 0.0,
|
||||
hold_seconds=trade.hold_seconds or 0,
|
||||
trigger_post_id=trade.trigger_post_id or 0,
|
||||
opened_at=trade.opened_at.isoformat(),
|
||||
closed_at=trade.closed_at.isoformat() if trade.closed_at else "",
|
||||
opened_at=iso_utc(trade.opened_at) or "",
|
||||
closed_at=iso_utc(trade.closed_at) or "",
|
||||
)
|
||||
|
||||
|
||||
|
||||
+152
-12
@@ -1,16 +1,30 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import BotTrade, Subscription
|
||||
from app.schemas import BotTrade as BotTradeSchema, UserResponse
|
||||
from app.models import BotTrade, Subscription, iso_utc
|
||||
from app.schemas import (
|
||||
BotTrade as BotTradeSchema,
|
||||
UserResponse,
|
||||
UserSettings,
|
||||
SetApiKeyRequest,
|
||||
SetApiKeyResponse,
|
||||
SetSettingsRequest,
|
||||
)
|
||||
from app.services.crypto import encrypt_api_key
|
||||
from app.services.signed_request import verify_signed_request
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Action names — must match frontend exactly (used in the signed message)
|
||||
ACTION_SET_API_KEY = "set_hl_api_key"
|
||||
ACTION_SET_SETTINGS = "set_settings"
|
||||
ACTION_VIEW_USER = "view_user"
|
||||
|
||||
|
||||
def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
|
||||
return BotTradeSchema(
|
||||
@@ -22,23 +36,89 @@ def _trade_to_schema(trade: BotTrade) -> BotTradeSchema:
|
||||
pnl_usd=trade.pnl_usd or 0.0,
|
||||
hold_seconds=trade.hold_seconds or 0,
|
||||
trigger_post_id=trade.trigger_post_id or 0,
|
||||
opened_at=trade.opened_at.isoformat(),
|
||||
closed_at=trade.closed_at.isoformat() if trade.closed_at else "",
|
||||
opened_at=iso_utc(trade.opened_at) or "",
|
||||
closed_at=iso_utc(trade.closed_at) or "",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/user/{wallet}/hl-api-key", response_model=SetApiKeyResponse)
|
||||
async def set_hl_api_key(
|
||||
wallet: str,
|
||||
body: SetApiKeyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wallet = wallet.lower().strip()
|
||||
if wallet != body.wallet.lower().strip():
|
||||
raise HTTPException(400, "Wallet mismatch between path and body")
|
||||
|
||||
api_key = body.api_key.strip()
|
||||
if not api_key.startswith("0x") or len(api_key) != 66:
|
||||
raise HTTPException(422, "Invalid API key format — must be a 0x-prefixed 64-char hex string")
|
||||
|
||||
# Signature binds to the full payload (including api_key) so a leaked sig
|
||||
# can't be reused to change the key.
|
||||
verify_signed_request(
|
||||
action=ACTION_SET_API_KEY,
|
||||
wallet=wallet,
|
||||
timestamp_ms=body.timestamp,
|
||||
signature=body.signature,
|
||||
body={"api_key": api_key},
|
||||
)
|
||||
|
||||
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
|
||||
sub = result.scalar_one_or_none()
|
||||
if sub is None:
|
||||
raise HTTPException(404, "Wallet not subscribed. Subscribe first.")
|
||||
|
||||
sub.hl_api_key = encrypt_api_key(api_key)
|
||||
await db.commit()
|
||||
|
||||
masked = f"...{api_key[-6:]}"
|
||||
logger.info("HL API key updated for wallet %s (masked: %s)", wallet, masked)
|
||||
return SetApiKeyResponse(status="ok", masked_key=masked)
|
||||
|
||||
|
||||
@router.get("/user/{wallet}/public")
|
||||
async def get_user_public(wallet: str, db: AsyncSession = Depends(get_db)):
|
||||
"""No-auth endpoint returning only boolean state. Safe to call on every
|
||||
page load to decide whether to show the Subscribe/API-key UI."""
|
||||
wallet = wallet.lower().strip()
|
||||
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
|
||||
sub = result.scalar_one_or_none()
|
||||
if sub is None:
|
||||
return {"wallet_address": wallet, "active": False, "hl_api_key_set": False}
|
||||
return {
|
||||
"wallet_address": wallet,
|
||||
"active": sub.active,
|
||||
"hl_api_key_set": bool(sub.hl_api_key),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/user/{wallet}", response_model=UserResponse)
|
||||
async def get_user(wallet: str, db: AsyncSession = Depends(get_db)):
|
||||
async def get_user(
|
||||
wallet: str,
|
||||
ts: int = Query(..., description="Signed timestamp (ms)"),
|
||||
sig: str = Query(..., description="EIP-191 signature"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wallet = wallet.lower().strip()
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.wallet_address == wallet)
|
||||
# Require a fresh signed timestamp from the owner — stops wallet-enumeration
|
||||
# and trade-history leakage. Frontend re-signs ~every 5 min via sessionStorage.
|
||||
verify_signed_request(
|
||||
action=ACTION_VIEW_USER,
|
||||
wallet=wallet,
|
||||
timestamp_ms=ts,
|
||||
signature=sig,
|
||||
body=None,
|
||||
allow_replay=True, # idempotent GET — allow sessionStorage caching for UX
|
||||
)
|
||||
|
||||
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
|
||||
sub = result.scalar_one_or_none()
|
||||
if sub is None:
|
||||
raise HTTPException(status_code=404, detail="Wallet not found")
|
||||
raise HTTPException(404, "Wallet not found")
|
||||
|
||||
# Personal trades (all — open and closed)
|
||||
trades_result = await db.execute(
|
||||
select(BotTrade)
|
||||
.where(BotTrade.wallet_address == wallet)
|
||||
@@ -47,13 +127,73 @@ async def get_user(wallet: str, db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
trades = trades_result.scalars().all()
|
||||
|
||||
# Mask hl_api_key: show only last 4 chars if set
|
||||
hl_api_key_set = bool(sub.hl_api_key)
|
||||
# Never return the encrypted blob or any decrypted key. The masked hint
|
||||
# shown to the user is derived lazily and only from the last 6 chars of
|
||||
# the stored blob's sha-hash, not the plaintext.
|
||||
if hl_api_key_set:
|
||||
import hashlib
|
||||
masked = "..." + hashlib.sha256(sub.hl_api_key.encode()).hexdigest()[-6:]
|
||||
else:
|
||||
masked = None
|
||||
|
||||
return UserResponse(
|
||||
wallet_address=sub.wallet_address,
|
||||
active=sub.active,
|
||||
subscribed_at=sub.subscribed_at.isoformat() if sub.subscribed_at else None,
|
||||
subscribed_at=iso_utc(sub.subscribed_at),
|
||||
hl_api_key_set=hl_api_key_set,
|
||||
hl_api_key_masked=masked,
|
||||
trades=[_trade_to_schema(t) for t in trades],
|
||||
settings=UserSettings(
|
||||
leverage=sub.leverage,
|
||||
position_size_usd=sub.position_size_usd,
|
||||
take_profit_pct=sub.take_profit_pct,
|
||||
stop_loss_pct=sub.stop_loss_pct,
|
||||
min_confidence=sub.min_confidence,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/user/{wallet}/settings", response_model=UserSettings)
|
||||
async def set_user_settings(
|
||||
wallet: str,
|
||||
body: SetSettingsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wallet = wallet.lower().strip()
|
||||
if wallet != body.wallet.lower().strip():
|
||||
raise HTTPException(400, "Wallet mismatch")
|
||||
|
||||
s = body.settings
|
||||
if not (1 <= s.leverage <= 50):
|
||||
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")
|
||||
if not (0 <= s.min_confidence <= 100):
|
||||
raise HTTPException(422, "min_confidence must be 0–100")
|
||||
|
||||
verify_signed_request(
|
||||
action=ACTION_SET_SETTINGS,
|
||||
wallet=wallet,
|
||||
timestamp_ms=body.timestamp,
|
||||
signature=body.signature,
|
||||
body=s.model_dump(),
|
||||
)
|
||||
|
||||
result = await db.execute(select(Subscription).where(Subscription.wallet_address == wallet))
|
||||
sub = result.scalar_one_or_none()
|
||||
if sub is None:
|
||||
raise HTTPException(404, "Wallet not subscribed")
|
||||
|
||||
sub.leverage = s.leverage
|
||||
sub.position_size_usd = s.position_size_usd
|
||||
sub.take_profit_pct = s.take_profit_pct
|
||||
sub.stop_loss_pct = s.stop_loss_pct
|
||||
sub.min_confidence = s.min_confidence
|
||||
await db.commit()
|
||||
logger.info("Settings updated for %s: %s", wallet, s.model_dump())
|
||||
return s
|
||||
|
||||
Reference in New Issue
Block a user