feat(api): real klines/trades from indexer + auto table creation
- charts.py: serve OHLCV bars & trades from Postgres (klines/trades tables written by indexer), no synthetic data - entities.py: add TradeModel/KlineModel matching indexer schema - main.py: idempotent create_all on startup - config.py: unify default DATABASE_URL with docker-compose/indexer
This commit is contained in:
@@ -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}
|
||||||
+2
-2
@@ -9,8 +9,8 @@ class Settings(BaseSettings):
|
|||||||
# 环境:development, staging, production
|
# 环境:development, staging, production
|
||||||
ENVIRONMENT: str = "development"
|
ENVIRONMENT: str = "development"
|
||||||
|
|
||||||
# 数据库配置 (PostgreSQL)
|
# 数据库配置 (PostgreSQL) — 与 docker-compose / indexer 保持一致
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/wtfx"
|
DATABASE_URL: str = "postgresql+asyncpg://wtfx_user:wtfx_pass@localhost:5432/wtfx_db"
|
||||||
|
|
||||||
# Redis 配置
|
# Redis 配置
|
||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
|||||||
+12
@@ -4,6 +4,9 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.routes.api import api_router
|
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
|
from app.ws.manager import ws_manager
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||||
@@ -12,8 +15,16 @@ logger = logging.getLogger("WTFX-Backend")
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
logger.info(f"Starting {settings.PROJECT_NAME} v{settings.VERSION} [{settings.ENVIRONMENT}]")
|
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
|
yield
|
||||||
logger.info("Shutting down WTFX Backend service...")
|
logger.info("Shutting down WTFX Backend service...")
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.PROJECT_NAME,
|
title=settings.PROJECT_NAME,
|
||||||
@@ -33,6 +44,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
# 注册 API 路由
|
# 注册 API 路由
|
||||||
app.include_router(api_router, prefix="/api")
|
app.include_router(api_router, prefix="/api")
|
||||||
|
app.include_router(kline_router, prefix="/api")
|
||||||
|
|
||||||
# WebSocket 实时订阅端点
|
# WebSocket 实时订阅端点
|
||||||
@app.websocket("/ws/market/{market_address}")
|
@app.websocket("/ws/market/{market_address}")
|
||||||
|
|||||||
@@ -118,3 +118,38 @@ class OrderModel(Base):
|
|||||||
fee = Column(Numeric(precision=36, scale=18), default=Decimal("0"), nullable=False)
|
fee = Column(Numeric(precision=36, scale=18), default=Decimal("0"), nullable=False)
|
||||||
tx_hash = Column(String(66), nullable=True)
|
tx_hash = Column(String(66), nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
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"),
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user