first commit

This commit is contained in:
k
2026-04-20 23:05:59 +08:00
commit 9a72566753
33 changed files with 2138 additions and 0 deletions
View File
+125
View File
@@ -0,0 +1,125 @@
import json
import logging
import anthropic
from app.config import settings
logger = logging.getLogger(__name__)
from typing import Optional
_client: Optional[anthropic.AsyncAnthropic] = None
SYSTEM_PROMPT = (
"You are a crypto trading signal analyst. Analyze Donald Trump's social media posts "
"and determine their likely impact on cryptocurrency markets. "
"Return only valid JSON with no additional text or markdown."
)
USER_PROMPT_TEMPLATE = """Analyze this Trump post for cryptocurrency trading signals.
Post: {text}
Respond with JSON only:
{{
"relevant": <true if this post could affect crypto/macro markets>,
"asset": "BTC" | "ETH" | null,
"sentiment": "bullish" | "bearish" | "neutral",
"signal": "buy" | "sell" | "short" | "hold",
"confidence": <0-100>,
"reasoning": "<1-2 sentences explaining the signal and why>"
}}
Signal rules:
- "buy": bullish crypto/macro signal → expect price rise → go long
- "sell": bearish signal for existing longs → exit position
- "short": strong bearish signal → expect price drop → go short
- "hold": relevant but unclear direction, or neutral macro
Bullish examples: pro-crypto policy, BTC reserve, anti-regulation, dollar weakness, tariff deal, market optimism
Bearish examples: SEC crackdowns, war escalation, economic sanctions, crypto ban threats, crisis
Not relevant: personal attacks, sports, endorsements, unrelated politics
Be strict on relevance — most posts are NOT relevant to crypto."""
_FALLBACK = {
"relevant": False,
"asset": None,
"sentiment": "neutral",
"signal": "hold",
"confidence": 0,
"reasoning": "",
}
def _get_client() -> anthropic.AsyncAnthropic:
global _client
if _client is None:
_client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return _client
async def analyze_post(text: str) -> dict:
"""Run Claude signal analysis on a Trump post.
Returns dict with keys: relevant, asset, sentiment, signal, confidence, reasoning.
Falls back to neutral hold on any error.
"""
try:
client = _get_client()
message = await client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=300,
system=SYSTEM_PROMPT,
messages=[
{
"role": "user",
"content": USER_PROMPT_TEMPLATE.format(text=text[:2000]),
}
],
)
raw = message.content[0].text.strip()
if raw.startswith("```"):
lines = raw.split("\n")
raw = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(raw)
sentiment = result.get("sentiment", "neutral")
if sentiment not in ("bullish", "bearish", "neutral"):
sentiment = "neutral"
signal = result.get("signal", "hold")
if signal not in ("buy", "sell", "short", "hold"):
signal = "hold"
confidence = int(result.get("confidence", 0))
confidence = max(0, min(100, confidence))
relevant = bool(result.get("relevant", False))
asset = result.get("asset")
if asset not in ("BTC", "ETH", None):
asset = None
reasoning = str(result.get("reasoning", ""))[:500]
return {
"relevant": relevant,
"asset": asset,
"sentiment": sentiment,
"signal": signal,
"confidence": confidence,
"reasoning": reasoning,
}
except anthropic.APIError as exc:
logger.error("Anthropic API error: %s", exc)
return dict(_FALLBACK)
except json.JSONDecodeError as exc:
logger.error("Failed to parse Claude JSON response: %s", exc)
return dict(_FALLBACK)
except Exception as exc:
logger.error("Unexpected error in analyze_post: %s", exc)
return dict(_FALLBACK)
+102
View File
@@ -0,0 +1,102 @@
import asyncio
import json
import logging
import httpx
import websockets
from app.config import settings
from app.services.price_store import price_store
from app.ws.manager import manager
logger = logging.getLogger(__name__)
ASSET_MAP = {
"btcusdt": "BTC",
"ethusdt": "ETH",
}
async def _process_message(raw: str):
try:
data = json.loads(raw)
# Combined stream wraps payload under "data"
payload = data.get("data", data)
if payload.get("e") != "kline":
return
kline = payload["k"]
symbol = kline["s"].lower()
asset = ASSET_MAP.get(symbol)
if asset is None:
return
candle = {
"time": kline["t"], # open time ms
"open": float(kline["o"]),
"high": float(kline["h"]),
"low": float(kline["l"]),
"close": float(kline["c"]),
"volume": float(kline["v"]),
}
price_store.update(asset, candle)
# Broadcast live price tick
await manager.broadcast({
"type": "price",
"asset": asset,
"price": candle["close"],
"time": candle["time"],
})
except Exception as exc:
logger.warning("Error processing Binance message: %s", exc)
async def fetch_historical(asset: str, symbol: str, interval: str = "1m", limit: int = 500):
"""Fetch historical klines from Binance REST API to pre-fill price_store."""
url = f"{settings.binance_rest_url}/api/v3/klines?symbol={symbol.upper()}&interval={interval}&limit={limit}"
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
for row in resp.json():
candle = {
"time": row[0], # open time ms
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
price_store.update(asset, candle)
logger.info("Loaded %d historical %s candles for %s", limit, interval, asset)
except Exception as exc:
logger.error("Failed to fetch historical data for %s: %s", asset, exc)
async def run_binance_ws():
"""Connect to Binance kline stream with exponential back-off on disconnect."""
# Pre-fill with historical data
await fetch_historical("BTC", "btcusdt", limit=500)
await fetch_historical("ETH", "ethusdt", limit=500)
backoff = 1
while True:
try:
logger.info("Connecting to Binance WebSocket: %s", settings.binance_ws_url)
async with websockets.connect(
settings.binance_ws_url,
ping_interval=20,
ping_timeout=10,
) as ws:
backoff = 1 # reset on successful connection
logger.info("Binance WebSocket connected.")
async for message in ws:
await _process_message(message)
except asyncio.CancelledError:
logger.info("Binance WebSocket task cancelled.")
return
except Exception as exc:
logger.error("Binance WebSocket error: %s. Reconnecting in %ds.", exc, backoff)
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
+131
View File
@@ -0,0 +1,131 @@
"""
Trading decision loop.
Called by the Truth Social scraper after each new post is analyzed and saved.
Iterates all active subscribers and executes trades on their behalf.
"""
import asyncio
import logging
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models import Post, BotTrade, Subscription
from app.services.hyperliquid import HyperliquidTrader
from app.services.price_store import price_store
logger = logging.getLogger(__name__)
# Thresholds
MIN_CONFIDENCE = 70 # ai_confidence must be >= this to trade
POSITION_SIZE_USD = 100 # per-trade size in USD (fixed for MVP)
MAX_HOLD_SECONDS = 3600 # force-close after 1 hour
async def process_post(post: Post, db: AsyncSession) -> None:
"""
Entry point called by the scraper after a new post is saved.
Skips non-relevant or low-confidence posts.
"""
if not post.relevant:
return
if (post.ai_confidence or 0) < MIN_CONFIDENCE:
logger.info("Post %d skipped: confidence %d < %d", post.id, post.ai_confidence, MIN_CONFIDENCE)
return
if post.sentiment == 'neutral':
return
asset = post.price_impact_asset or 'BTC'
side = 'long' if post.sentiment == 'bullish' else 'short'
logger.info("Signal: post=%d asset=%s side=%s confidence=%d", post.id, asset, side, post.ai_confidence)
result = await db.execute(select(Subscription).where(Subscription.active == True))
subscribers = result.scalars().all()
if not subscribers:
logger.info("No active subscribers, skipping trade execution")
return
tasks = [_execute_for_subscriber(sub, post, asset, side, db) for sub in subscribers]
await asyncio.gather(*tasks, return_exceptions=True)
async def _execute_for_subscriber(
sub: Subscription,
post: Post,
asset: str,
side: str,
db: AsyncSession,
) -> None:
if not sub.hl_api_key:
logger.warning("Subscriber %s has no HL API key, skipping", sub.wallet_address)
return
try:
trader = HyperliquidTrader(sub.hl_api_key)
# Check for existing open position to avoid doubling up
open_positions = await trader.get_open_positions()
already_open = any(p.get('coin') == asset for p in open_positions)
if already_open:
logger.info("Subscriber %s already has open %s position, skipping", sub.wallet_address, asset)
return
result = await trader.open_position(asset, side, POSITION_SIZE_USD)
entry_price = result.get('fill_price', 0.0)
trade = BotTrade(
asset=asset,
side=side,
entry_price=entry_price,
wallet_address=sub.wallet_address,
trigger_post_id=post.id,
opened_at=datetime.now(timezone.utc).replace(tzinfo=None),
hl_order_id=result.get('order_id'),
)
db.add(trade)
await db.commit()
await db.refresh(trade)
logger.info("Opened %s %s for %s @ %.2f (trade_id=%d)", side, asset, sub.wallet_address, entry_price, trade.id)
# Schedule close after MAX_HOLD_SECONDS
asyncio.create_task(_close_after_hold(trade.id, sub.hl_api_key, asset, sub.wallet_address))
except Exception as e:
logger.error("Trade execution failed for %s: %s", sub.wallet_address, e)
async def _close_after_hold(trade_id: int, api_key: str, asset: str, wallet: str) -> None:
await asyncio.sleep(MAX_HOLD_SECONDS)
from app.database import AsyncSessionLocal
async with AsyncSessionLocal() as db:
try:
result = await db.execute(
select(BotTrade).where(BotTrade.id == trade_id, BotTrade.closed_at.is_(None))
)
trade = result.scalar_one_or_none()
if trade is None:
return # already closed
trader = HyperliquidTrader(api_key)
close_result = await trader.close_position(asset)
exit_price = close_result.get('fill_price', 0.0)
now = datetime.now(timezone.utc)
hold_secs = int((now - trade.opened_at.replace(tzinfo=timezone.utc)).total_seconds())
pnl = (exit_price - trade.entry_price) * (1 if trade.side == 'long' else -1)
# Approximate PnL: size_usd * pct_change
pnl_usd = POSITION_SIZE_USD * (pnl / trade.entry_price) if trade.entry_price else 0.0
trade.exit_price = exit_price
trade.pnl_usd = round(pnl_usd, 2)
trade.hold_seconds = hold_secs
trade.closed_at = now
await db.commit()
logger.info("Closed %s %s for %s @ %.2f PnL=%.2f", trade.side, asset, wallet, exit_price, pnl_usd)
except Exception as e:
logger.error("Failed to close trade %d: %s", trade_id, e)
+176
View File
@@ -0,0 +1,176 @@
import asyncio
import logging
from functools import partial
from eth_account import Account
from hyperliquid.exchange import Exchange
from hyperliquid.info import Info
logger = logging.getLogger(__name__)
HL_MAINNET = "https://api.hyperliquid.xyz"
# Coin name mapping: our asset -> Hyperliquid coin
COIN_MAP = {
"BTC": "BTC",
"ETH": "ETH",
}
class HyperliquidTrader:
def __init__(self, private_key: str):
self.account = Account.from_key(private_key)
self.exchange = Exchange(self.account, HL_MAINNET)
self.info = Info(HL_MAINNET)
self._loop = asyncio.get_event_loop()
# ── helpers ──────────────────────────────────────────────────────────────
async def _run_sync(self, fn, *args, **kwargs):
"""Run a blocking SDK call in the default thread pool."""
return await self._loop.run_in_executor(None, partial(fn, *args, **kwargs))
def _wallet(self) -> str:
return self.account.address
# ── public interface ─────────────────────────────────────────────────────
async def get_balance(self) -> float:
"""Return USDC balance (withdrawable)."""
try:
state = await self._run_sync(
self.info.user_state, self._wallet()
)
return float(state.get("withdrawable", 0))
except Exception as exc:
logger.error("get_balance error: %s", exc)
return 0.0
async def open_position(self, asset: str, side: str, size_usd: float) -> dict:
"""Place a market order.
Args:
asset: "BTC" or "ETH"
side: "long" (buy) or "short" (sell)
size_usd: notional size in USD
Returns:
{"order_id": str, "fill_price": float}
"""
coin = COIN_MAP.get(asset.upper(), asset.upper())
is_buy = side.lower() == "long"
try:
# Get current price to compute size in coins
meta = await self._run_sync(self.info.meta)
universe = {u["name"]: u for u in meta.get("universe", [])}
coin_meta = universe.get(coin, {})
sz_decimals = int(coin_meta.get("szDecimals", 3))
# Use mid-price approximation from user state
all_mids = await self._run_sync(self.info.all_mids)
mid_price = float(all_mids.get(coin, 0))
if mid_price <= 0:
raise ValueError(f"Could not get mid price for {coin}")
size_coins = round(size_usd / mid_price, sz_decimals)
# Slippage: 1% for longs (limit above mid), 1% for shorts (limit below mid)
slippage = 0.01
if is_buy:
limit_px = round(mid_price * (1 + slippage), 1)
else:
limit_px = round(mid_price * (1 - slippage), 1)
order_result = await self._run_sync(
self.exchange.order,
coin,
is_buy,
size_coins,
limit_px,
{"limit": {"tif": "Ioc"}}, # IOC ~ market
)
status = order_result.get("response", {}).get("data", {})
filled = status.get("statuses", [{}])[0]
fill_price = float(filled.get("filled", {}).get("avgPx", mid_price) or mid_price)
order_id = str(filled.get("resting", {}).get("oid", ""))
logger.info(
"Opened %s %s position: size=%.4f coins @ %.2f",
side, coin, size_coins, fill_price,
)
return {"order_id": order_id, "fill_price": fill_price}
except Exception as exc:
logger.error("open_position error: %s", exc)
raise
async def close_position(self, asset: str) -> dict:
"""Close all open positions for the given asset.
Returns:
{"fill_price": float}
"""
coin = COIN_MAP.get(asset.upper(), asset.upper())
try:
positions = await self.get_open_positions()
target = next(
(p for p in positions if p.get("coin") == coin), None
)
if target is None:
logger.warning("No open position found for %s", coin)
return {"fill_price": 0.0}
szi = float(target.get("szi", 0))
is_buy = szi < 0 # closing a short means buying
all_mids = await self._run_sync(self.info.all_mids)
mid_price = float(all_mids.get(coin, 0))
slippage = 0.01
if is_buy:
limit_px = round(mid_price * (1 + slippage), 1)
else:
limit_px = round(mid_price * (1 - slippage), 1)
close_result = await self._run_sync(
self.exchange.order,
coin,
is_buy,
abs(szi),
limit_px,
{"limit": {"tif": "Ioc"}},
reduce_only=True,
)
status = close_result.get("response", {}).get("data", {})
filled = status.get("statuses", [{}])[0]
fill_price = float(filled.get("filled", {}).get("avgPx", mid_price) or mid_price)
logger.info("Closed %s position @ %.2f", coin, fill_price)
return {"fill_price": fill_price}
except Exception as exc:
logger.error("close_position error: %s", exc)
raise
async def get_open_positions(self) -> list:
"""Return list of open positions from Hyperliquid."""
try:
state = await self._run_sync(self.info.user_state, self._wallet())
positions = []
for asset_pos in state.get("assetPositions", []):
pos = asset_pos.get("position", {})
szi = float(pos.get("szi", 0))
if szi != 0:
positions.append({
"coin": pos.get("coin"),
"szi": szi,
"entry_px": float(pos.get("entryPx", 0) or 0),
"unrealized_pnl": float(pos.get("unrealizedPnl", 0) or 0),
})
return positions
except Exception as exc:
logger.error("get_open_positions error: %s", exc)
return []
+144
View File
@@ -0,0 +1,144 @@
"""
历史帖子价格回溯
对 DB 中没有价格数据的帖子,从 Binance 拉历史 K 线计算涨跌幅
"""
import asyncio
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import Post
logger = logging.getLogger(__name__)
# 每次从 Binance 拉多少分钟的 1m K 线(覆盖 1h 涨跌幅计算需要至少 60 根)
FETCH_WINDOW_MINUTES = 90
async def _fetch_klines(symbol: str, start_ms: int, limit: int = 90) -> list:
url = (
f"{settings.binance_rest_url}/api/v3/klines"
f"?symbol={symbol}&interval=1m&startTime={start_ms}&limit={limit}"
)
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(url)
resp.raise_for_status()
return resp.json()
def _price_at(klines: list, target_ms: int) -> Optional[float]:
"""找最接近 target_ms 的收盘价"""
best = None
best_diff = float("inf")
for row in klines:
diff = abs(row[0] - target_ms)
if diff < best_diff:
best_diff = diff
best = float(row[4]) # close
return best
def _pct_change(klines: list, from_ms: int, delta_minutes: int) -> Optional[float]:
from_price = _price_at(klines, from_ms)
to_price = _price_at(klines, from_ms + delta_minutes * 60 * 1000)
if from_price and to_price and from_price != 0:
return round((to_price - from_price) / from_price * 100, 4)
return None
async def backfill_price_impact(db_session_factory, asset: str = "BTC") -> None:
"""
对 DB 中 relevant=True 但没有价格数据的帖子补充价格回溯。
对 relevant=False 的帖子,做简单判断(标题含 crypto/bitcoin/btc 关键词则标记为相关)。
"""
symbol = "BTCUSDT" if asset == "BTC" else "ETHUSDT"
async with db_session_factory() as db:
# 拿所有没有价格数据的帖子
result = await db.execute(
select(Post)
.where(Post.price_at_post == None)
.order_by(Post.published_at.asc())
)
posts = result.scalars().all()
logger.info("找到 %d 条帖子需要价格回溯 (asset=%s)", len(posts), asset)
if not posts:
return
# 关键词判断是否与加密相关(快速,不用 Claude API)
crypto_keywords = [
"bitcoin", "btc", "crypto", "cryptocurrency", "blockchain",
"ethereum", "eth", "digital currency", "defi", "coinbase",
"sec", "regulation", "tariff", "dollar", "inflation", "fed",
"economy", "market", "trade", "sanctions", "iran", "china",
]
def is_relevant(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in crypto_keywords)
saved = 0
errors = 0
for i, post in enumerate(posts):
try:
published_at = post.published_at
if published_at.tzinfo is None:
published_at = published_at.replace(tzinfo=timezone.utc)
start_ms = int(published_at.timestamp() * 1000)
# 判断相关性(用关键词,不消耗 Claude API)
relevant = is_relevant(post.text)
sentiment = "neutral"
if relevant:
t = post.text.lower()
bullish_kw = ["great", "win", "winning", "strong", "best", "love", "beautiful", "tremendous", "amazing", "pro-crypto", "bitcoin reserve"]
bearish_kw = ["bad", "terrible", "war", "crisis", "sanction", "ban", "regulate", "crack", "fraud", "scam"]
if any(k in t for k in bullish_kw):
sentiment = "bullish"
elif any(k in t for k in bearish_kw):
sentiment = "bearish"
# 拉 Binance 历史价格
klines = await _fetch_klines(symbol, start_ms, limit=FETCH_WINDOW_MINUTES)
price_at_post = _price_at(klines, start_ms)
m5 = _pct_change(klines, start_ms, 5)
m15 = _pct_change(klines, start_ms, 15)
m1h = _pct_change(klines, start_ms, 60)
# 更新帖子
async with db_session_factory() as db:
result = await db.execute(select(Post).where(Post.id == post.id))
p = result.scalar_one_or_none()
if p:
p.relevant = relevant
p.sentiment = sentiment
p.price_impact_asset = asset if relevant else None
p.price_at_post = price_at_post
p.price_impact_m5 = m5 if relevant else None
p.price_impact_m15 = m15 if relevant else None
p.price_impact_m1h = m1h if relevant else None
await db.commit()
saved += 1
if (i + 1) % 20 == 0:
logger.info("进度: %d/%d 已处理,已保存 %d", i + 1, len(posts), saved)
# 避免触发 Binance 限速(1200 requests/min
await asyncio.sleep(0.1)
except Exception as exc:
errors += 1
logger.error("帖子 id=%d 回溯失败: %s", post.id, exc)
await asyncio.sleep(1)
logger.info("✅ 价格回溯完成: 共处理 %d 条,成功 %d 条,失败 %d", len(posts), saved, errors)
+138
View File
@@ -0,0 +1,138 @@
import logging
from collections import deque
from datetime import datetime, timezone
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
# 7 days of 1-minute candles
MAX_CANDLES = 7 * 24 * 60 # 10080
TIMEFRAME_MINUTES: Dict[str, int] = {
"1m": 1,
"1H": 60,
"4H": 240,
"1D": 1440,
"1W": 10080,
}
class PriceStore:
def __init__(self):
self._candles: Dict[str, deque] = {
"BTC": deque(maxlen=MAX_CANDLES),
"ETH": deque(maxlen=MAX_CANDLES),
}
def update(self, asset: str, candle: dict):
"""Add or replace the latest 1m candle for the asset."""
asset = asset.upper()
if asset not in self._candles:
self._candles[asset] = deque(maxlen=MAX_CANDLES)
buf = self._candles[asset]
# If the last candle has the same timestamp, replace it (in-progress bar)
if buf and buf[-1]["time"] == candle["time"]:
buf[-1] = candle
else:
buf.append(candle)
def get_price_at(self, asset: str, timestamp: datetime) -> Optional[float]:
"""Return the close price of the candle closest to timestamp."""
asset = asset.upper()
buf = self._candles.get(asset)
if not buf:
return None
ts = timestamp.replace(tzinfo=None)
target_unix = int(ts.timestamp()) * 1000 # candle times are ms
best = None
best_diff = float("inf")
for candle in buf:
diff = abs(candle["time"] - target_unix)
if diff < best_diff:
best_diff = diff
best = candle
return best["close"] if best else None
def get_pct_change(
self, asset: str, from_ts: datetime, minutes: int
) -> Optional[float]:
"""Return % change from from_ts to from_ts + minutes."""
asset = asset.upper()
buf = self._candles.get(asset)
if not buf:
return None
from_unix_ms = int(from_ts.replace(tzinfo=None).timestamp()) * 1000
to_unix_ms = from_unix_ms + minutes * 60 * 1000
# Find candle at from_ts
from_candle = self._closest_candle(buf, from_unix_ms)
to_candle = self._closest_candle(buf, to_unix_ms)
if from_candle is None or to_candle is None:
return None
if from_candle["close"] == 0:
return None
pct = (to_candle["close"] - from_candle["close"]) / from_candle["close"] * 100
return round(pct, 4)
def _closest_candle(self, buf: deque, target_unix_ms: int) -> Optional[dict]:
best = None
best_diff = float("inf")
for candle in buf:
diff = abs(candle["time"] - target_unix_ms)
if diff < best_diff:
best_diff = diff
best = candle
return best
def get_candles(self, asset: str, timeframe: str = "1H", limit: int = 200) -> List[dict]:
"""Aggregate 1m candles into the requested timeframe and return the last `limit` bars."""
asset = asset.upper()
buf = self._candles.get(asset)
if not buf:
return []
tf_minutes = TIMEFRAME_MINUTES.get(timeframe, 60)
if tf_minutes == 1:
candles = list(buf)[-limit:]
return [{**c, "time": c["time"] // 1000} for c in candles]
# Aggregate
aggregated: Dict[int, dict] = {}
tf_ms = tf_minutes * 60 * 1000
for candle in buf:
bucket = (candle["time"] // tf_ms) * tf_ms
if bucket not in aggregated:
aggregated[bucket] = {
"time": bucket,
"open": candle["open"],
"high": candle["high"],
"low": candle["low"],
"close": candle["close"],
"volume": candle["volume"],
}
else:
agg = aggregated[bucket]
agg["high"] = max(agg["high"], candle["high"])
agg["low"] = min(agg["low"], candle["low"])
agg["close"] = candle["close"]
agg["volume"] += candle["volume"]
sorted_candles = sorted(aggregated.values(), key=lambda c: c["time"])
result = sorted_candles[-limit:]
# Convert ms → seconds for lightweight-charts
return [{**c, "time": c["time"] // 1000} for c in result]
def latest_price(self, asset: str) -> Optional[float]:
asset = asset.upper()
buf = self._candles.get(asset)
if not buf:
return None
return buf[-1]["close"]
price_store = PriceStore()