diff --git a/app/api/routes/charts.py b/app/api/routes/charts.py new file mode 100644 index 0000000..88d2d0f --- /dev/null +++ b/app/api/routes/charts.py @@ -0,0 +1,93 @@ +import logging +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.dependencies import get_db_session +from app.models.entities import KlineModel, TradeModel + +logger = logging.getLogger(__name__) + +kline_router = APIRouter(prefix="/v1/charts") + +@kline_router.get("/klines") +async def get_market_klines( + market_address: str = Query(..., description="预测市场合约地址"), + outcome_index: int = Query(0, description="结果选项序号 (0-based)"), + timeframe: str = Query("5s", description="时间粒度: 1s, 5s, 1m, 1h"), + limit: int = Query(1000, le=5000, description="返回的最大 K 线根数"), + db: AsyncSession = Depends(get_db_session), +): + """ + 【TradingView 标准 OHLCV K 线历史接口】 + 返回由 Indexer 从链上真实成交事件确定性聚合的 K 线。 + 无成交历史时返回空数组(绝不生成假数据)。 + """ + m_addr = market_address.lower() + + # 当前仅支持 5 秒基准聚合(Indexer 按 5s 窗口写入),其他粒度在后续版本扩展 + rows = ( + await db.execute( + select(KlineModel) + .where( + KlineModel.market_address == m_addr, + KlineModel.outcome_index == outcome_index, + ) + .order_by(KlineModel.bar_time.asc()) + .limit(limit) + ) + ).scalars().all() + + bars = [ + { + "time": row.bar_time, + "open": float(row.open), + "high": float(row.high), + "low": float(row.low), + "close": float(row.close), + "volume": float(row.volume), + } + for row in rows + ] + + return { + "market": m_addr, + "outcome_index": outcome_index, + "timeframe": timeframe, + "has_trades": len(bars) > 0, + "bars": bars, + } + +@kline_router.get("/trades") +async def get_market_trades( + market_address: str = Query(..., description="预测市场合约地址"), + outcome_index: int = Query(0, description="结果选项序号 (0-based)"), + limit: int = Query(100, le=500), + db: AsyncSession = Depends(get_db_session), +): + """真实链上逐笔成交明细(由 Indexer 写入)""" + m_addr = market_address.lower() + rows = ( + await db.execute( + select(TradeModel) + .where( + TradeModel.market_address == m_addr, + TradeModel.outcome_index == outcome_index, + ) + .order_by(TradeModel.ts.desc()) + .limit(limit) + ) + ).scalars().all() + + trades = [ + { + "tx_hash": row.tx_hash, + "type": row.trade_type, + "price": float(row.price), + "volume": float(row.volume), + "block_number": row.block_number, + "ts": row.ts, + } + for row in rows + ] + return {"market": m_addr, "outcome_index": outcome_index, "trades": trades} diff --git a/app/config.py b/app/config.py index fe95726..4108e03 100644 --- a/app/config.py +++ b/app/config.py @@ -9,8 +9,8 @@ class Settings(BaseSettings): # 环境:development, staging, production ENVIRONMENT: str = "development" - # 数据库配置 (PostgreSQL) - DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/wtfx" + # 数据库配置 (PostgreSQL) — 与 docker-compose / indexer 保持一致 + DATABASE_URL: str = "postgresql+asyncpg://wtfx_user:wtfx_pass@localhost:5432/wtfx_db" # Redis 配置 REDIS_URL: str = "redis://localhost:6379/0" diff --git a/app/main.py b/app/main.py index 29491a0..46f2cc0 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,9 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.api.routes.api import api_router +from app.api.routes.charts import kline_router +from app.api.dependencies import engine +from app.models.entities import Base from app.ws.manager import ws_manager logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") @@ -12,8 +15,16 @@ logger = logging.getLogger("WTFX-Backend") @asynccontextmanager async def lifespan(app: FastAPI): logger.info(f"Starting {settings.PROJECT_NAME} v{settings.VERSION} [{settings.ENVIRONMENT}]") + # 幂等建表(含 Indexer 写入的 klines/trades),避免依赖外部建表步骤 + try: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + logger.info("Database tables ensured.") + except Exception as e: + logger.error(f"Failed to ensure database tables: {e}") yield logger.info("Shutting down WTFX Backend service...") + await engine.dispose() app = FastAPI( title=settings.PROJECT_NAME, @@ -33,6 +44,7 @@ app.add_middleware( # 注册 API 路由 app.include_router(api_router, prefix="/api") +app.include_router(kline_router, prefix="/api") # WebSocket 实时订阅端点 @app.websocket("/ws/market/{market_address}") diff --git a/app/models/entities.py b/app/models/entities.py index be8f059..9b1c0c7 100644 --- a/app/models/entities.py +++ b/app/models/entities.py @@ -118,3 +118,38 @@ class OrderModel(Base): fee = Column(Numeric(precision=36, scale=18), default=Decimal("0"), nullable=False) tx_hash = Column(String(66), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + +class TradeModel(Base): + """链上真实成交明细 (由 Indexer 确定性投影写入)""" + __tablename__ = "trades" + + id = Column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True) + tx_hash = Column(String(66), nullable=True, index=True) + market_address = Column(String(42), nullable=False, index=True) + outcome_index = Column(BigInteger, nullable=False) + trade_type = Column(String(8), nullable=False) # "MINT" | "REDEEM" + price = Column(Numeric(precision=36, scale=18), nullable=False) + volume = Column(Numeric(precision=36, scale=18), nullable=False) + block_number = Column(BigInteger, nullable=True) + ts = Column(BigInteger, nullable=True, index=True) + + __table_args__ = ( + Index("idx_trades_market_ts", "market_address", "outcome_index", "ts"), + ) + +class KlineModel(Base): + """秒级 OHLCV K 线 (由 Indexer 按 5 秒窗口确定性聚合写入)""" + __tablename__ = "klines" + + market_address = Column(String(42), primary_key=True) + outcome_index = Column(BigInteger, primary_key=True) + bar_time = Column(BigInteger, primary_key=True) + open = Column(Numeric(precision=36, scale=18), nullable=False) + high = Column(Numeric(precision=36, scale=18), nullable=False) + low = Column(Numeric(precision=36, scale=18), nullable=False) + close = Column(Numeric(precision=36, scale=18), nullable=False) + volume = Column(Numeric(precision=36, scale=18), nullable=False) + + __table_args__ = ( + Index("idx_klines_market_time", "market_address", "outcome_index", "bar_time"), + )