Compare commits
5
Commits
30dc22a989
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6662ccde5 | ||
|
|
fef69fa4ba | ||
|
|
13686de581 | ||
|
|
37546f18da | ||
|
|
1356233179 |
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
+36
-2
@@ -7,13 +7,14 @@ from app.schemas.dtos import (
|
||||
TokenResponse,
|
||||
AccountBalanceDTO,
|
||||
LedgerEntryDTO,
|
||||
CreateMarketMetadataRequest,
|
||||
ResolveMarketRequest,
|
||||
MarketDTO,
|
||||
CreateOrderRequest,
|
||||
OrderDTO
|
||||
)
|
||||
from app.domains.ledger.service import LedgerService
|
||||
from app.domains.market.service import MarketService
|
||||
from app.domains.trading.service import TradingService
|
||||
from app.domains.trading.service import MarketService, TradingService
|
||||
|
||||
api_router = APIRouter(prefix="/v1")
|
||||
|
||||
@@ -25,6 +26,39 @@ async def login_with_wallet(req: UserAuthRequest, session: AsyncSession = Depend
|
||||
return TokenResponse(access_token=token, address=req.address.lower())
|
||||
|
||||
# ================= Market Routes =================
|
||||
@api_router.post("/markets/metadata", response_model=MarketDTO)
|
||||
async def create_market_metadata(
|
||||
req: CreateMarketMetadataRequest,
|
||||
current_user: str = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session)
|
||||
):
|
||||
"""保存预测市场的链下元数据 (标题, 描述, 类别, 2~255个选项)"""
|
||||
market_service = MarketService(session)
|
||||
market = await market_service.save_market_metadata(
|
||||
market_address=req.market_address,
|
||||
question_id=req.question_id,
|
||||
title=req.title,
|
||||
description=req.description,
|
||||
category=req.category,
|
||||
outcomes=req.outcomes,
|
||||
resolution_time=req.resolution_time,
|
||||
creator=current_user
|
||||
)
|
||||
return MarketDTO(
|
||||
market_address=market.market_address,
|
||||
question_id=market.question_id,
|
||||
title=market.title,
|
||||
description=market.description,
|
||||
category=market.category,
|
||||
tier=market.tier,
|
||||
creator=market.creator,
|
||||
status=market.status.value if hasattr(market.status, 'value') else str(market.status),
|
||||
outcomes=market.outcomes,
|
||||
winning_outcome=market.winning_outcome,
|
||||
resolution_time=market.resolution_time,
|
||||
created_at=market.created_at
|
||||
)
|
||||
|
||||
@api_router.get("/markets", response_model=List[MarketDTO])
|
||||
async def get_markets(
|
||||
category: Optional[str] = None,
|
||||
|
||||
@@ -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
|
||||
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"
|
||||
|
||||
@@ -12,6 +12,38 @@ class MarketService:
|
||||
def __init__(self, session: AsyncSession):
|
||||
self.session = session
|
||||
|
||||
async def save_market_metadata(
|
||||
self,
|
||||
market_address: str,
|
||||
question_id: str,
|
||||
title: str,
|
||||
description: Optional[str],
|
||||
category: str,
|
||||
outcomes: List[str],
|
||||
resolution_time,
|
||||
creator: str
|
||||
) -> MarketModel:
|
||||
market = MarketModel(
|
||||
market_address=market_address.lower(),
|
||||
question_id=question_id.lower(),
|
||||
title=title,
|
||||
description=description,
|
||||
category=category or "Others",
|
||||
tier=1,
|
||||
creator=creator.lower(),
|
||||
curve_address="0xF0E189974c413506098AFB6Bc6Bf4C6715fBf7B1",
|
||||
collateral_address="0xe776e957953EA69b7Eaa9d7d4098aBC076bDD5E7",
|
||||
fee_rate=Decimal("0.006"),
|
||||
resolution_time=resolution_time,
|
||||
outcomes=outcomes,
|
||||
num_outcomes=len(outcomes),
|
||||
status=MarketStatus.ACTIVE,
|
||||
created_at_block=0
|
||||
)
|
||||
self.session.add(market)
|
||||
await self.session.flush()
|
||||
return market
|
||||
|
||||
async def list_markets(self, category: Optional[str] = None, status: Optional[str] = None) -> List[MarketModel]:
|
||||
stmt = select(MarketModel)
|
||||
if category:
|
||||
@@ -47,7 +79,7 @@ class TradingService:
|
||||
order_id = str(uuid.uuid4())
|
||||
fee = amount * Decimal("0.006") # 0.6% protocol fee
|
||||
|
||||
# 1. 记账并扣款/冻结
|
||||
# 1. 记账并扣款/冻结 (若提供 vault_address 则关联金库)
|
||||
await self.ledger_service.record_trade(
|
||||
address=user_address,
|
||||
market_address=market_address,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any
|
||||
from web3 import AsyncWeb3, AsyncHTTPProvider
|
||||
from eth_account import Account
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RelayerService:
|
||||
"""
|
||||
【WTFX Relayer 链上代付撮合服务】
|
||||
代表持有 Session Key 授权的用户,将其交易免 Gas 费提交至链上执行
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.w3 = AsyncWeb3(AsyncHTTPProvider(settings.ROBINHOOD_TESTNET_RPC))
|
||||
# 管理员/Relayer 私钥
|
||||
self.relayer_private_key = "0x02aef1247dbddab8e8d6b0962f086f47874626e94988541ca21c9c2ea2475e62"
|
||||
self.relayer_account = Account.from_key(self.relayer_private_key)
|
||||
|
||||
async def execute_vault_transaction(
|
||||
self,
|
||||
vault_address: str,
|
||||
target_market: str,
|
||||
call_data: str,
|
||||
value: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
通过 Relayer 调用用户 Vault 的 execute() 函数
|
||||
"""
|
||||
try:
|
||||
vault_checksum = self.w3.to_checksum_address(vault_address)
|
||||
target_checksum = self.w3.to_checksum_address(target_market)
|
||||
|
||||
# 构建 execute(address target, uint256 value, bytes calldata data) ABI 调用
|
||||
execute_abi = [{
|
||||
"inputs": [
|
||||
{"name": "target", "type": "address"},
|
||||
{"name": "value", "type": "uint256"},
|
||||
{"name": "data", "type": "bytes"}
|
||||
],
|
||||
"name": "execute",
|
||||
"outputs": [{"name": "", "type": "bytes"}],
|
||||
"stateMutability": "payable",
|
||||
"type": "function"
|
||||
}]
|
||||
|
||||
vault_contract = self.w3.eth.contract(address=vault_checksum, abi=execute_abi)
|
||||
nonce = await self.w3.eth.get_transaction_count(self.relayer_account.address)
|
||||
gas_price = await self.w3.eth.gas_price
|
||||
|
||||
data_bytes = bytes.fromhex(call_data[2:]) if call_data.startswith("0x") else bytes.fromhex(call_data)
|
||||
|
||||
tx = await vault_contract.functions.execute(
|
||||
target_checksum,
|
||||
value,
|
||||
data_bytes
|
||||
).build_transaction({
|
||||
"from": self.relayer_account.address,
|
||||
"nonce": nonce,
|
||||
"gasPrice": gas_price,
|
||||
"chainId": 46630
|
||||
})
|
||||
|
||||
signed_tx = self.relayer_account.sign_transaction(tx)
|
||||
tx_hash = await self.w3.eth.send_raw_transaction(signed_tx.rawTransaction)
|
||||
logger.info(f"Relayer tx submitted: {tx_hash.hex()} for Vault {vault_address}")
|
||||
return tx_hash.hex()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute relayer tx: {e}")
|
||||
raise e
|
||||
|
||||
relayer_service = RelayerService()
|
||||
+12
@@ -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}")
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
+14
-5
@@ -28,13 +28,15 @@ class LedgerEntryDTO(BaseModel):
|
||||
related_market: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class CreateMarketRequest(BaseModel):
|
||||
class CreateMarketMetadataRequest(BaseModel):
|
||||
market_address: str
|
||||
question_id: str
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
category: str = "Crypto"
|
||||
tier: int = 1
|
||||
category: str = "Others" # 默认 Others,支持用户自定义
|
||||
outcomes: List[str] = Field(..., min_length=2, max_length=255) # 支持最多 255 个选项
|
||||
resolution_time: datetime
|
||||
outcomes: List[str] = Field(..., min_length=2, max_length=8)
|
||||
creator: str
|
||||
|
||||
class MarketDTO(BaseModel):
|
||||
market_address: str
|
||||
@@ -50,12 +52,19 @@ class MarketDTO(BaseModel):
|
||||
resolution_time: datetime
|
||||
created_at: datetime
|
||||
|
||||
class ResolveMarketRequest(BaseModel):
|
||||
market_address: str
|
||||
question_id: str
|
||||
winning_outcome_index: int = Field(..., ge=0, le=255)
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
market_address: str
|
||||
outcome_index: int = Field(..., ge=0, le=7)
|
||||
vault_address: Optional[str] = None
|
||||
outcome_index: int = Field(..., ge=0, le=255)
|
||||
side: str = Field(..., pattern="^(BUY|SELL)$")
|
||||
amount: Decimal = Field(..., gt=0)
|
||||
price: Optional[Decimal] = None
|
||||
session_signature: Optional[str] = None
|
||||
|
||||
class OrderDTO(BaseModel):
|
||||
order_id: str
|
||||
|
||||
Reference in New Issue
Block a user