feat: multi-symbol support and per-tf start_time in data_provider

This commit is contained in:
jackyu66git
2026-05-13 18:34:39 +08:00
parent d8e3cdd9e9
commit 0dd8f8a585
2 changed files with 739 additions and 66 deletions
+19 -6
View File
@@ -4,15 +4,28 @@
"BTC/USDT:USDT", "BTC/USDT:USDT",
"ETH/USDT:USDT", "ETH/USDT:USDT",
"SOL/USDT:USDT", "SOL/USDT:USDT",
"WIF/USDT:USDT", "XAU/USDT:USDT",
"AAVE/USDT:USDT", "XAG/USDT:USDT",
"SUI/USDT:USDT", "SAGA/USDT:USDT",
"1INCH/USDT:USDT", "CL/USDT:USDT",
"ZEC/USDT:USDT",
"XRP/USDT:USDT",
"DOGE/USDT:USDT", "DOGE/USDT:USDT",
"UNI/USDT:USDT" "BNB/USDT:USDT",
"SUI/USDT:USDT",
"BILL/USDT:USDT",
"BZ/USDT:USDT",
"LAB/USDT:USDT",
"TON/USDT:USDT",
"CRCL/USDT:USDT",
"SNDK/USDT:USDT",
"1000PEPE/USDT:USDT",
"CHIP/USDT:USDT"
], ],
"start_time": "2024-01-01T00:00:00Z", "start_time": "2024-01-01T00:00:00Z",
"start_time_per_tf": {
"1m": "2026-01-01T00:00:00Z"
},
"timeframes": ["1m", "1h", "1d", "1w"], "timeframes": ["1m", "1h", "1d", "1w"],
"data_dir": "./data" "data_dir": "./data"
} }
+717 -57
View File
@@ -19,6 +19,7 @@ import ccxt # type: ignore
import pandas as pd # type: ignore import pandas as pd # type: ignore
from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
import uvicorn import uvicorn
from technical.util import resample_to_interval from technical.util import resample_to_interval
@@ -35,6 +36,10 @@ TIMEFRAME_TO_MS: Dict[str, int] = {
"1d": 86_400_000, "1d": 86_400_000,
"1w": 604_800_000, "1w": 604_800_000,
} }
# 内存最大缓存的 K 线数量(超出部分从 CSV 按需读取,不限制则不限量)
MAX_CANDLES_IN_MEMORY: Dict[str, int] = {
"1m": 129_600, # 3 个月(3 * 30 * 24 * 60
}
# 每个基础周期可派生出的合成周期列表(由该基础周期 K 线 resample 得到) # 每个基础周期可派生出的合成周期列表(由该基础周期 K 线 resample 得到)
DERIVED_TIMEFRAME_PLAN: Dict[str, List[str]] = { DERIVED_TIMEFRAME_PLAN: Dict[str, List[str]] = {
"1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m", "45m"], "1m": ["2m", "3m", "4m", "5m", "10m", "15m", "20m", "25m", "30m", "45m"],
@@ -213,6 +218,14 @@ class DataProvider:
if start is None: if start is None:
raise ValueError("配置文件必须包含 start_time 字段") raise ValueError("配置文件必须包含 start_time 字段")
self.start_time_ms: int = start self.start_time_ms: int = start
# 按周期独立 start_time(从 start_time_per_tf 字段读取),未配置则回退到全局 start_time
self.start_time_map: Dict[str, int] = {}
start_per_tf = self.config.get("start_time_per_tf", {})
if isinstance(start_per_tf, dict):
for tf, ts_str in start_per_tf.items():
tf_start = parse_timestamp(ts_str)
if tf_start is not None:
self.start_time_map[tf] = tf_start
self.exchange = self._init_exchange() self.exchange = self._init_exchange()
self.data: Dict[str, Dict[str, List[Dict[str, float]]]] = { self.data: Dict[str, Dict[str, List[Dict[str, float]]]] = {
symbol: {tf: [] for tf in self.timeframes} for symbol in self.symbols symbol: {tf: [] for tf in self.timeframes} for symbol in self.symbols
@@ -236,6 +249,8 @@ class DataProvider:
self._stop_event = threading.Event() self._stop_event = threading.Event()
self._fetch_thread: Optional[threading.Thread] = None self._fetch_thread: Optional[threading.Thread] = None
self._persist_thread: Optional[threading.Thread] = None self._persist_thread: Optional[threading.Thread] = None
self._backfill_thread: Optional[threading.Thread] = None
self._cold_start_done = False
# 记录断线后需要从哪个 since 重新拉取(symbol -> timeframe -> since_ms # 记录断线后需要从哪个 since 重新拉取(symbol -> timeframe -> since_ms
self._resume_since: Dict[str, Dict[str, int]] = {} self._resume_since: Dict[str, Dict[str, int]] = {}
# 恢复点持久化文件 # 恢复点持久化文件
@@ -270,6 +285,10 @@ class DataProvider:
unique.append(item) unique.append(item)
return unique return unique
def _get_start_time(self, timeframe: str) -> int:
"""获取指定周期的 start_time(毫秒)。优先使用 start_time_per_tf,否则回退全局。"""
return self.start_time_map.get(timeframe, self.start_time_ms)
def _validate_timeframes(self, configured: Optional[Iterable[str]]) -> List[str]: def _validate_timeframes(self, configured: Optional[Iterable[str]]) -> List[str]:
"""校验周期在允许集合内;未配置则默认 TIMEFRAME_ORDER 全部;顺序优先按 TIMEFRAME_ORDER。""" """校验周期在允许集合内;未配置则默认 TIMEFRAME_ORDER 全部;顺序优先按 TIMEFRAME_ORDER。"""
if not configured: if not configured:
@@ -348,6 +367,19 @@ class DataProvider:
logger.debug("时间周期 %s 合并后K线数量: %s", timeframe, len(ordered)) logger.debug("时间周期 %s 合并后K线数量: %s", timeframe, len(ordered))
return ordered return ordered
def _trim(self, symbol: str, timeframe: str) -> None:
"""将内存缓存裁剪到 MAX_CANDLES_IN_MEMORY 上限(全量数据已在 CSV 中)。"""
max_count = MAX_CANDLES_IN_MEMORY.get(timeframe)
if max_count is None:
return
sym_data = self.data.get(symbol)
if sym_data is None:
return
candles = sym_data.get(timeframe)
if candles is None or len(candles) <= max_count:
return
sym_data[timeframe] = candles[-max_count:]
def _write_to_disk(self, symbol: str, timeframe: str, data: List[Dict[str, float]]) -> None: def _write_to_disk(self, symbol: str, timeframe: str, data: List[Dict[str, float]]) -> None:
"""先写临时文件再 replace,避免写入中断导致 CSV 损坏。""" """先写临时文件再 replace,避免写入中断导致 CSV 损坏。"""
path = self._data_file_path(symbol, timeframe) path = self._data_file_path(symbol, timeframe)
@@ -368,75 +400,159 @@ class DataProvider:
logger.info("交易对 %s 时间周期 %s 已写入磁盘 (%s 根K线)", symbol, timeframe, len(data)) logger.info("交易对 %s 时间周期 %s 已写入磁盘 (%s 根K线)", symbol, timeframe, len(data))
def _fetch_history(self, symbol: str, timeframe: str, since_ms: int) -> List[List[float]]: def _fetch_history(self, symbol: str, timeframe: str, since_ms: int) -> List[List[float]]:
"""从 since_ms 分页拉取直到接近当前时间;遇限频则 sleep 重试。""" """从 since_ms 分页拉取直到接近当前时间
当 since_ms 早于交易所数据保留期时,先从最新数据往回探测有效起点,
再向后分页补齐。"""
results: List[List[float]] = [] results: List[List[float]] = []
limit = 1500 limit = 1500
now_ms = self.exchange.milliseconds() now_ms = self.exchange.milliseconds()
tf_ms = TIMEFRAME_TO_MS[timeframe] tf_ms = TIMEFRAME_TO_MS[timeframe]
fetch_since = since_ms
# 先尝试直接拉取
first_batch = self._fetch_ohlcv_retry(symbol, timeframe, since=since_ms, limit=limit)
# 若返回空,或首根时间远晚于 since(交易所忽略了过老的 since),启用反向探测
gap_ms = tf_ms * limit * 2
first_skipped = (
not first_batch
or (first_batch[0][0] - since_ms > gap_ms and first_batch[-1][0] >= now_ms - tf_ms * 10)
)
if first_skipped:
logger.info("since 过老被交易所跳过,启用反向探测 %s %s (since=%s)", symbol, timeframe, to_utc_iso(since_ms))
# 反向探测:从当前时间往前翻页,找到实际最早可用的 K 线
all_collected: List[List[float]] = []
search_since = max(0, now_ms - tf_ms * limit)
search_rounds = 0
max_search = 200
earliest_ts = None
while search_rounds < max_search:
search_rounds += 1
batch = self._fetch_ohlcv_retry(symbol, timeframe, since=search_since, limit=limit)
if not batch:
break
all_collected = batch + all_collected
first_ts = batch[0][0]
if first_ts == search_since or first_ts <= since_ms:
earliest_ts = first_ts
break
search_since = max(0, first_ts - tf_ms * limit)
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 0.2)
if earliest_ts is None and all_collected:
earliest_ts = all_collected[0][0]
if earliest_ts is not None:
logger.info("反向探测找到最早 %s: %s (%s)", symbol, to_utc_iso(earliest_ts), timeframe)
# 用收集到的数据作为起点,继续向前分页到 now
results = all_collected
if results:
last_ts = results[-1][0]
if last_ts < now_ms - tf_ms:
extra = self._fetch_ohlcv_paginate(symbol, timeframe, last_ts + tf_ms, now_ms, limit)
results.extend(extra)
logger.info("交易对 %s 时间周期 %s 拉取历史K线数量: %s", symbol, timeframe, len(results))
return results
# 直接拉取成功,正常向后分页
results = list(first_batch)
last_ts = results[-1][0]
if last_ts < now_ms - tf_ms:
extra = self._fetch_ohlcv_paginate(symbol, timeframe, last_ts + tf_ms, now_ms, limit)
results.extend(extra)
logger.info("交易对 %s 时间周期 %s 拉取历史K线数量: %s", symbol, timeframe, len(results))
return results
def _fetch_ohlcv_retry(self, symbol: str, timeframe: str, since: int, limit: int) -> List[List[float]]:
"""带重试的单次 fetch_ohlcv,限频/网络错误时等待重试。"""
max_attempts = 5
for attempt in range(max_attempts):
try:
return self.exchange.fetch_ohlcv(symbol, timeframe=timeframe, since=since, limit=limit)
except ccxt.RateLimitExceeded as exc:
wait_s = (self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 1) * (attempt + 1)
logger.warning("触发频率限制,等待 %.1fs: %s", wait_s, exc)
time.sleep(wait_s)
except ccxt.BaseError as exc:
logger.error("拉取K线失败 (%s, %s, since=%s): %s (attempt %s/%s)",
timeframe, symbol, since, exc, attempt + 1, max_attempts)
time.sleep(3 * (attempt + 1))
logger.error("拉取K线失败,已达最大重试次数: %s %s", symbol, timeframe)
return []
def _fetch_ohlcv_paginate(self, symbol: str, timeframe: str, start_ms: int, end_ms: int, limit: int) -> List[List[float]]:
"""从 start_ms 向后分页拉取到 end_ms。"""
results: List[List[float]] = []
tf_ms = TIMEFRAME_TO_MS[timeframe]
fetch_since = start_ms
max_rounds = 5000 max_rounds = 5000
rounds = 0 rounds = 0
while fetch_since < now_ms and rounds < max_rounds: while fetch_since < end_ms and rounds < max_rounds:
rounds += 1 rounds += 1
try: candles = self._fetch_ohlcv_retry(symbol, timeframe, since=fetch_since, limit=limit)
candles = self.exchange.fetch_ohlcv(
symbol,
timeframe=timeframe,
since=fetch_since,
limit=limit,
)
except ccxt.RateLimitExceeded as exc:
logger.warning("触发频率限制,等待: %s", exc)
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 1)
continue
except ccxt.BaseError as exc:
logger.error("拉取历史K线失败 (%s, %s): %s", timeframe, fetch_since, exc)
time.sleep(5)
continue
if not candles: if not candles:
break break
results.extend(candles) results.extend(candles)
last_ts = candles[-1][0] last_ts = candles[-1][0]
fetch_since = last_ts + tf_ms fetch_since = last_ts + tf_ms
if last_ts >= now_ms - tf_ms: if last_ts >= end_ms - tf_ms:
break break
time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 0.2) time.sleep(self.exchange.rateLimit / 1000 if self.exchange.rateLimit else 0.2)
logger.info("交易对 %s 时间周期 %s 拉取历史K线数量: %s", symbol, timeframe, len(results))
return results return results
def initialize(self) -> None: def initialize(self) -> None:
"""阻塞式启动:加载本地、从倒数第二根或配置起点补历史、写盘并 set _ready""" """快速启动:加载本地 CSV 到内存,立即设 _ready 让服务可用。后台再补拉交易所数据"""
logger.info("开始初始化数据提供商") logger.info("开始初始化数据提供商(仅加载本地数据)")
for symbol in self.symbols: for symbol in self.symbols:
for timeframe in self.timeframes: for timeframe in self.timeframes:
existing = self._load_local(symbol, timeframe) existing = self._load_local(symbol, timeframe)
with self._lock:
self.data.setdefault(symbol, {})[timeframe] = existing
self._trim(symbol, timeframe)
logger.info("交易对 %s 时间周期 %s 已加载本地K线: %s", symbol, timeframe, len(existing))
self._ready.set()
logger.info("数据初始化完成,服务已就绪(后台将补拉交易所数据)")
def _cold_start_backfill(self) -> None:
"""后台一次性回填:从交易所拉取每个 symbol/timeframe 的缺失历史,合并后写盘。"""
logger.info("开始后台回填交易所数据")
for symbol in self.symbols:
for timeframe in self.timeframes:
if self._stop_event.is_set():
return
with self._lock:
existing = list(self.data.get(symbol, {}).get(timeframe, []))
tf_ms = TIMEFRAME_TO_MS[timeframe] tf_ms = TIMEFRAME_TO_MS[timeframe]
last_ts = existing[-1]["timestamp"] if existing else None last_ts = existing[-1]["timestamp"] if existing else None
# 计算 fetch_since:优先从数据末尾补,但若数据量极少
#(刷新线程抢先写入的少量近期数据),则回退到 start_time 做全量回填
if last_ts is not None: if last_ts is not None:
if len(existing) >= 2: if len(existing) > RECENT_CANDLE_LIMIT * 3:
# 从倒数第二根起拉,避免最后一根未收盘重复/缺口 # 已有足够历史数据,仅增量补拉
fetch_since = existing[-2]["timestamp"] fetch_since = (existing[-2]["timestamp"] if len(existing) >= 2
else max(0, existing[-1]["timestamp"] - tf_ms))
else: else:
fetch_since = max(0, last_ts - tf_ms) # 只有刷新线程抢先写入的少量数据,从 start_time 全量回填
fetch_since = self._get_start_time(timeframe)
logger.info("数据量不足 (%s 根),从 start_time 回填 %s %s",
len(existing), symbol, timeframe)
else: else:
fetch_since = self.start_time_ms fetch_since = self._get_start_time(timeframe)
logger.debug( try:
"初始化拉取参数", history = self._fetch_history(symbol, timeframe, fetch_since)
extra={ # 再次读取最新数据后合并,避免覆盖并发刷新线程写入的更新
"symbol": symbol, with self._lock:
"timeframe": timeframe, current = list(self.data.get(symbol, {}).get(timeframe, []))
"existing_last": last_ts, merged = self._merge_candles(timeframe, current, history)
"fetch_since": fetch_since, self.data[symbol][timeframe] = merged
"tf_ms": tf_ms, self._write_to_disk(symbol, timeframe, merged)
}, self._trim(symbol, timeframe)
) self._notify_update(symbol, timeframe)
history = self._fetch_history(symbol, timeframe, fetch_since) logger.info("回填完成 %s %s: +%s 根新K线", symbol, timeframe, len(history))
merged = self._merge_candles(timeframe, existing, history) except Exception as exc:
with self._lock: logger.error("回填失败 %s %s: %s", symbol, timeframe, exc)
self.data.setdefault(symbol, {})[timeframe] = merged self._set_resume_since(symbol, timeframe, fetch_since)
self._write_to_disk(symbol, timeframe, merged) logger.info("后台回填全部完成")
self._ready.set() self._cold_start_done = True
logger.info("数据初始化完成")
def resample_df(self, df: pd.DataFrame, interval: int) -> pd.DataFrame: def resample_df(self, df: pd.DataFrame, interval: int) -> pd.DataFrame:
"""将基础周期 DataFrame 聚合为 interval 分钟周期(freqtrade technical.util)。""" """将基础周期 DataFrame 聚合为 interval 分钟周期(freqtrade technical.util)。"""
@@ -549,23 +665,28 @@ class DataProvider:
logger.error("数据更新回调异常: %s", exc) logger.error("数据更新回调异常: %s", exc)
def start_background_workers(self) -> None: def start_background_workers(self) -> None:
"""启动增量刷新线程与周期性落盘线程""" """启动后台线程:冷启动回填、增量刷新周期性落盘。"""
if self._fetch_thread and self._fetch_thread.is_alive():
return
self._stop_event.clear() self._stop_event.clear()
self._backfill_thread = threading.Thread(target=self._cold_start_backfill, name="backfill-loop", daemon=True)
self._fetch_thread = threading.Thread(target=self._refresh_loop, name="refresh-loop", daemon=True) self._fetch_thread = threading.Thread(target=self._refresh_loop, name="refresh-loop", daemon=True)
self._persist_thread = threading.Thread(target=self._persist_loop, name="persist-loop", daemon=True) self._persist_thread = threading.Thread(target=self._persist_loop, name="persist-loop", daemon=True)
self._backfill_thread.start()
self._fetch_thread.start() self._fetch_thread.start()
self._persist_thread.start() self._persist_thread.start()
logger.info("后台线程已启动") logger.info("后台线程已启动(回填 + 增量刷新 + 落盘)")
def stop(self) -> None: def stop(self) -> None:
"""停止后台线程(应用关闭时 lifespan finally 调用)。""" """停止后台线程(应用关闭时 lifespan finally 调用)。"""
self._stop_event.set() self._stop_event.set()
if self._fetch_thread: for thread, name in [
self._fetch_thread.join(timeout=5) (self._backfill_thread, "backfill"),
if self._persist_thread: (self._fetch_thread, "fetch"),
self._persist_thread.join(timeout=5) (self._persist_thread, "persist"),
]:
if thread:
thread.join(timeout=5)
if thread.is_alive():
logger.warning("后台线程 %s 未能在 5s 内结束", name)
logger.info("数据提供商已停止") logger.info("数据提供商已停止")
def _refresh_loop(self) -> None: def _refresh_loop(self) -> None:
@@ -591,6 +712,7 @@ class DataProvider:
self.data[symbol][timeframe] = merged self.data[symbol][timeframe] = merged
self._notify_update(symbol, timeframe) self._notify_update(symbol, timeframe)
self._clear_resume_since(symbol, timeframe) self._clear_resume_since(symbol, timeframe)
self._trim(symbol, timeframe)
else: else:
# 正常增量获取最近若干根K线 # 正常增量获取最近若干根K线
candles = self.exchange.fetch_ohlcv( candles = self.exchange.fetch_ohlcv(
@@ -605,6 +727,7 @@ class DataProvider:
merged = self._merge_candles(timeframe, current, candles) merged = self._merge_candles(timeframe, current, candles)
self.data[symbol][timeframe] = merged self.data[symbol][timeframe] = merged
self._notify_update(symbol, timeframe) self._notify_update(symbol, timeframe)
self._trim(symbol, timeframe)
except ccxt.BaseError as exc: except ccxt.BaseError as exc:
logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc) logger.error("更新最新K线失败 (%s %s): %s", symbol, timeframe, exc)
# 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K # 记录应当从何时恢复拉取,避免重连后从当前时间开始导致丢K
@@ -613,10 +736,10 @@ class DataProvider:
if current: if current:
last_ts = int(current[-1]["timestamp"]) last_ts = int(current[-1]["timestamp"])
else: else:
last_ts = self.start_time_ms last_ts = self._get_start_time(timeframe)
tf_ms = TIMEFRAME_TO_MS[timeframe] tf_ms = TIMEFRAME_TO_MS[timeframe]
# 回退一个周期,确保包含可能未完全收盘的K线,去重由 _merge_candles 处理 # 回退一个周期,确保包含可能未完全收盘的K线,去重由 _merge_candles 处理
since_ms = max(self.start_time_ms, last_ts - tf_ms) since_ms = max(self._get_start_time(timeframe), last_ts - tf_ms)
self._set_resume_since(symbol, timeframe, since_ms) self._set_resume_since(symbol, timeframe, since_ms)
time.sleep(2) time.sleep(2)
continue continue
@@ -629,7 +752,7 @@ class DataProvider:
self._persist_all() self._persist_all()
def _persist_all(self) -> None: def _persist_all(self) -> None:
"""在锁内复制 data 后落盘,避免长时间持锁。""" """在锁内复制 data 后落盘,避免长时间持锁。有限制的时间周期跳过不写(CSV 已有全量历史)。"""
if not self._ready.is_set(): if not self._ready.is_set():
return return
with self._lock: with self._lock:
@@ -639,6 +762,8 @@ class DataProvider:
} }
for symbol, tf_map in snapshot.items(): for symbol, tf_map in snapshot.items():
for timeframe, data in tf_map.items(): for timeframe, data in tf_map.items():
if timeframe in MAX_CANDLES_IN_MEMORY:
continue # 内存只有尾部 N 根,不覆盖 CSV 全量
self._write_to_disk(symbol, timeframe, data) self._write_to_disk(symbol, timeframe, data)
# 周期性也保存一次恢复点,保证一致性 # 周期性也保存一次恢复点,保证一致性
self._save_resume_since() self._save_resume_since()
@@ -663,9 +788,18 @@ class DataProvider:
end_ms: Optional[int], end_ms: Optional[int],
limit: Optional[int], limit: Optional[int],
) -> List[Dict[str, float]]: ) -> List[Dict[str, float]]:
"""从内存读取已缓存的基础周期 K 线并按时间/limit 裁剪。""" """从内存读取已缓存的基础周期 K 线并按时间/limit 裁剪;内存不足时从 CSV 补充"""
with self._lock: with self._lock:
candles = list(self.data.get(symbol, {}).get(timeframe, [])) candles = list(self.data.get(symbol, {}).get(timeframe, []))
# 有限制的时间周期,若内存不够老则从 CSV 补充历史
if timeframe in MAX_CANDLES_IN_MEMORY and candles and start_ms is not None:
if start_ms < candles[0]["timestamp"]:
csv_candles = self._load_local(symbol, timeframe)
if csv_candles:
merged = {c["timestamp"]: c for c in csv_candles}
for c in candles:
merged[c["timestamp"]] = c
candles = sorted(merged.values(), key=lambda x: x["timestamp"])
if start_ms is not None: if start_ms is not None:
candles = [row for row in candles if row["timestamp"] >= start_ms] candles = [row for row in candles if row["timestamp"] >= start_ms]
if end_ms is not None: if end_ms is not None:
@@ -853,6 +987,532 @@ def create_app(provider: DataProvider) -> FastAPI:
"ready": provider.is_ready(), "ready": provider.is_ready(),
} }
@app.get("/api-manual", response_class=HTMLResponse)
async def api_manual():
"""数据服务 API 使用手册页面。"""
derived_plan = DERIVED_TIMEFRAME_PLAN
timeframe_ms = TIMEFRAME_TO_MS
memory_limits = MAX_CANDLES_IN_MEMORY
base_tfs = provider.timeframes
derived_tfs = provider.get_derived_timeframes()
all_tfs = provider.get_available_timeframes()
symbols = provider.symbols
# Build derived timeframe table rows
derived_rows = ""
for base, derived_list in derived_plan.items():
present = "" if base in base_tfs else ""
derived_rows += f"<tr><td><code>{base}</code></td><td>{present}</td><td>{', '.join(f'<code>{d}</code>' for d in derived_list)}</td></tr>"
# Build memory limits table rows
memory_rows = ""
for tf in base_tfs:
limit = memory_limits.get(tf)
if limit is None:
span = "unlimited"
elif tf == "1m":
span = f"{limit:,} candles (~3 months)"
elif tf == "1h":
span = f"{limit:,} candles (~1 year)"
else:
span = f"{limit:,} candles"
memory_rows += f"<tr><td><code>{tf}</code></td><td>{span}</td></tr>"
# Pre-build config JSON example to avoid f-string escaping issues
import json as _json
_config_obj = {
"exchange": "binance",
"symbols": ["BTC/USDT:USDT", "ETH/USDT:USDT"],
"start_time": "2024-01-01T00:00:00Z",
"start_time_per_tf": {"1m": "2026-01-01T00:00:00Z"},
"timeframes": ["1m", "1h", "1d", "1w"],
"data_dir": "./data",
}
config_example = _json.dumps(_config_obj, indent=2, ensure_ascii=False)
return f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Data Provider — API Manual</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {{
font-family: "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f8f9fa; color: #333; padding: 0;
}}
.hero {{
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
color: #fff; padding: 48px 0 40px; margin-bottom: 32px;
}}
.hero h1 {{ font-size: 2rem; font-weight: 700; margin: 0; }}
.hero .badge {{ font-size: 0.8rem; vertical-align: middle; margin-left: 10px; }}
.hero p {{ opacity: 0.85; margin: 8px 0 0; font-size: 0.95rem; }}
.nav-pills .nav-link {{ color: #495057; border-radius: 6px; padding: 6px 16px; font-size: 0.9rem; }}
.nav-pills .nav-link.active {{ background: #0d6efd; }}
.section {{ display: none; }}
.section.active {{ display: block; }}
.card {{ border: 1px solid #e9ecef; box-shadow: 0 1px 3px rgba(0,0,0,0.04); margin-bottom: 20px; }}
.card-header {{ background: #fff; border-bottom: 1px solid #e9ecef; font-weight: 600; font-size: 0.95rem; padding: 12px 18px; }}
pre {{ background: #1e1e2e; color: #cdd6f4; border-radius: 8px; padding: 16px; font-size: 0.85rem; line-height: 1.6; overflow-x: auto; }}
pre code {{ color: inherit; }}
.method-badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; margin-right: 8px; width: 52px; text-align: center; }}
.method-GET {{ background: #d1fae5; color: #065f46; }}
.method-WS {{ background: #ede9fe; color: #5b21b6; }}
.endpoint-path {{ font-family: monospace; font-size: 0.9rem; }}
.table {{ font-size: 0.875rem; }}
.table code {{ background: #f1f3f5; padding: 1px 5px; border-radius: 3px; font-size: 0.82rem; }}
.status-ready {{ color: #198754; }}
.status-notready {{ color: #dc3545; }}
.param-required {{ color: #dc3545; font-weight: 600; }}
.toc-item {{ cursor: pointer; }}
footer {{ border-top: 1px solid #dee2e6; margin-top: 48px; padding: 20px 0; color: #868e96; font-size: 0.85rem; text-align: center; }}
.endpoint-card {{ border-left: 3px solid #0d6efd; }}
.endpoint-card.ws {{ border-left-color: #7c3aed; }}
</style>
</head>
<body>
<div class="hero">
<div class="container">
<h1>Data Provider <span class="badge bg-success">v1.0.0</span></h1>
<p>Cryptocurrency OHLCV data microservice — REST + WebSocket API for candlestick (K-line) data</p>
<p style="font-size:0.82rem; opacity:0.7;">Exchange: {provider.exchange_name} &middot; Symbols: {len(symbols)} &middot; Timeframes: {len(all_tfs)} &middot; Status: <span class="{'status-ready' if provider.is_ready() else 'status-notready'}">{'Ready' if provider.is_ready() else 'Initializing…'}</span></p>
</div>
</div>
<div class="container" style="max-width: 960px;">
<!-- Tabs -->
<ul class="nav nav-pills mb-4" id="manualTabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-section="overview">Overview</button></li>
<li class="nav-item"><button class="nav-link" data-section="rest">REST API</button></li>
<li class="nav-item"><button class="nav-link" data-section="ws">WebSocket</button></li>
<li class="nav-item"><button class="nav-link" data-section="timeframes">Timeframes</button></li>
<li class="nav-item"><button class="nav-link" data-section="config">Configuration</button></li>
<li class="nav-item"><button class="nav-link" data-section="examples">Code Examples</button></li>
</ul>
<!-- ==================== OVERVIEW ==================== -->
<div class="section active" id="section-overview">
<div class="card">
<div class="card-header">What is Data Provider?</div>
<div class="card-body">
<p><strong>Data Provider</strong> is a standalone microservice that fetches, caches, and serves cryptocurrency OHLCV (Open/High/Low/Close/Volume) candlestick data from the Binance Futures exchange via CCXT.</p>
<p>It decouples data acquisition from analysis — consumers request historical or real-time market data through a simple REST or WebSocket API instead of talking to exchange APIs directly.</p>
<hr>
<h6>Key Features</h6>
<div class="row">
<div class="col-md-6">
<ul class="small">
<li>Fetches base OHLCV at <b>1m, 1h, 1d, 1w</b> from Binance</li>
<li>Synthesizes <b>{len(derived_tfs)} derived timeframes</b> by resampling</li>
<li>Dual storage: in-memory cache + CSV persistence</li>
<li>Background refresh every <b>{RECENT_FETCH_INTERVAL}s</b></li>
</ul>
</div>
<div class="col-md-6">
<ul class="small">
<li>REST endpoint at <code>/api/candles</code></li>
<li>WebSocket real-time push at <code>/ws</code></li>
<li>Automatic disconnect recovery</li>
<li>Fully Dockerized, single JSON config file</li>
</ul>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Architecture</div>
<div class="card-body">
<pre style="background:#f8f9fa; color:#333; text-align:center; font-size:0.8rem; line-height:1.4;">
┌───────────────────────┐
│ Binance Futures │
│ (ccxt driver) │
└──────────┬────────────┘
│ fetch_ohlcv()
┌──────────┴────────────┐
│ DataProvider │
│ │
│ · In-memory cache │
│ · CSV persistence │
│ · Background refresh │
│ · Derived timeframe │
│ resample engine │
└──────┬────────┬────────┘
│ │
REST │ │ WebSocket
│ │
┌──────┴────────┴────────┐
│ Consumers (Flask app, │
│ browser, scripts…) │
└────────────────────────┘</pre>
</div>
</div>
<div class="card">
<div class="card-header">Endpoints at a Glance</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Method</th><th>Path</th><th>Description</th></tr></thead>
<tbody>
<tr><td><span class="method-badge method-GET">GET</span></td><td class="endpoint-path">/</td><td>Service info &amp; health summary</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td><td class="endpoint-path">/health</td><td>Structured health check (JSON)</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td><td class="endpoint-path">/timeframes</td><td>List available base &amp; derived timeframes</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td><td class="endpoint-path">/api/candles</td><td>Fetch OHLCV candles</td></tr>
<tr><td><span class="method-badge method-WS">WS</span></td><td class="endpoint-path">/ws</td><td>Real-time K-line streaming</td></tr>
<tr><td><span class="method-badge method-GET">GET</span></td><td class="endpoint-path">/api-manual</td><td>This page</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">Data Model</div>
<div class="card-body">
<p class="small">Every candle object (REST response, WebSocket payload) has this structure:</p>
<table class="table table-bordered table-sm">
<thead class="table-light"><tr><th>Field</th><th>Type</th><th>Example</th></tr></thead>
<tbody>
<tr><td><code>timestamp</code></td><td>int</td><td><code>1704067200000</code></td></tr>
<tr><td><code>datetime</code></td><td>string</td><td><code>"2024-01-01T00:00:00Z"</code></td></tr>
<tr><td><code>open</code></td><td>float</td><td><code>42314.0</code></td></tr>
<tr><td><code>high</code></td><td>float</td><td><code>44266.0</code></td></tr>
<tr><td><code>low</code></td><td>float</td><td><code>42207.9</code></td></tr>
<tr><td><code>close</code></td><td>float</td><td><code>44230.2</code></td></tr>
<tr><td><code>volume</code></td><td>float</td><td><code>206424.144</code></td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ==================== REST API ==================== -->
<div class="section" id="section-rest">
<h5 class="mb-3"><span class="method-badge method-GET">GET</span> /health</h5>
<div class="card endpoint-card">
<div class="card-body">
<p class="small">Health check — use <code>ready</code> to determine if the service finished cold-start initialization.</p>
<p><strong>Response</strong> <span class="text-muted small">200 OK</span></p>
<pre><code>{{
"status": "ok",
"exchange": "binance",
"symbols": ["BTC/USDT:USDT", "ETH/USDT:USDT"],
"base_timeframes": ["1m", "1h", "1d", "1w"],
"derived_timeframes": ["2m","3m","4m","5m",...],
"timeframes": ["1m","2m","3m",...],
"ready": true
}}</code></pre>
<button class="btn btn-sm btn-outline-secondary" onclick="fetch('/health').then(r=>r.json()).then(d=>alert(JSON.stringify(d,null,2)))">Try it</button>
</div>
</div>
<h5 class="mb-3 mt-4"><span class="method-badge method-GET">GET</span> /api/candles</h5>
<div class="card endpoint-card">
<div class="card-body">
<p class="small">The primary data endpoint. Returns OHLCV candlestick data for a given symbol and timeframe.</p>
<p><strong>Query Parameters</strong></p>
<table class="table table-bordered table-sm">
<thead class="table-light"><tr><th>Param</th><th>Type</th><th>Required</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>symbol</code></td><td>string</td><td><span class="param-required">Yes</span></td><td>—</td><td>Trading pair, e.g. <code>BTC/USDT:USDT</code></td></tr>
<tr><td><code>tf</code></td><td>string</td><td>No</td><td><code>1m</code></td><td>Timeframe — any base or derived</td></tr>
<tr><td><code>start</code></td><td>int</td><td>No</td><td>—</td><td>Start time, UTC milliseconds</td></tr>
<tr><td><code>end</code></td><td>int</td><td>No</td><td>—</td><td>End time, UTC milliseconds</td></tr>
<tr><td><code>limit</code></td><td>int</td><td>No</td><td>—</td><td>Max candles to return</td></tr>
</tbody>
</table>
<p><strong>Response</strong> <span class="text-muted small">200 OK — array of candle objects</span></p>
<pre><code>[
{{
"timestamp": 1704067200000,
"datetime": "2024-01-01T00:00:00Z",
"open": 42314.0,
"high": 44266.0,
"low": 42207.9,
"close": 44230.2,
"volume": 206424.144
}}
]</code></pre>
<p class="small text-muted">At least one of <code>start</code>/<code>end</code> or <code>limit</code> should be provided. If none given, all in-memory candles are returned (subject to memory limits).</p>
</div>
</div>
<h5 class="mb-3 mt-4"><span class="method-badge method-GET">GET</span> /timeframes</h5>
<div class="card endpoint-card">
<div class="card-body">
<p class="small">Lists all available timeframes: base (fetched from exchange) and derived (synthesized by resampling).</p>
<pre><code>{{
"base_timeframes": ["1m","1h","1d","1w"],
"derived_timeframes": ["2m","3m","4m","5m",...],
"timeframes": ["1m","2m","3m",...]
}}</code></pre>
</div>
</div>
<h5 class="mb-3 mt-4"><span class="method-badge method-GET">GET</span> /</h5>
<div class="card endpoint-card">
<div class="card-body">
<p class="small">Root endpoint — returns service name, exchange, symbols, timeframes, and ready status. Same data as <code>/health</code>.</p>
</div>
</div>
</div>
<!-- ==================== WEBSOCKET ==================== -->
<div class="section" id="section-ws">
<div class="card endpoint-card ws">
<div class="card-header"><span class="method-badge method-WS">WS</span> /ws — Real-time K-line Streaming</div>
<div class="card-body">
<p class="small">Connect to receive live incremental candle updates. Subscribe to specific symbol/timeframe pairs; receive a full snapshot followed by real-time push.</p>
<h6 class="mt-3">Connection URL</h6>
<pre><code>ws://localhost:80/ws</code></pre>
<p class="small text-muted">Replace <code>localhost:80</code> with the server address if connecting remotely.</p>
<h6 class="mt-3">Client → Server Messages</h6>
<table class="table table-bordered table-sm">
<thead class="table-light"><tr><th>Action</th><th>Payload</th><th>Description</th></tr></thead>
<tbody>
<tr>
<td><code>subscribe</code></td>
<td><pre style="margin:0;padding:4px 8px;font-size:0.78rem;"><code>{{"action":"subscribe","symbol":"BTC/USDT:USDT","timeframe":"1m"}}</code></pre></td>
<td>Subscribe to a symbol/timeframe. Server replies with <code>subscribed</code>, then a <code>snapshot</code> of recent candles.</td>
</tr>
<tr>
<td><code>unsubscribe</code></td>
<td><pre style="margin:0;padding:4px 8px;font-size:0.78rem;"><code>{{"action":"unsubscribe","symbol":"BTC/USDT:USDT","timeframe":"1m"}}</code></pre></td>
<td>Stop receiving updates for this pair.</td>
</tr>
<tr>
<td><code>ping</code></td>
<td><pre style="margin:0;padding:4px 8px;font-size:0.78rem;"><code>{{"action":"ping"}}</code></pre></td>
<td>Keepalive ping. Server replies with <code>pong</code>.</td>
</tr>
</tbody>
</table>
<h6 class="mt-3">Server → Client Messages</h6>
<table class="table table-bordered table-sm">
<thead class="table-light"><tr><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>subscribed</code></td><td>Confirms subscription: <code>{{"type":"subscribed","symbol":"...","timeframe":"..."}}</code></td></tr>
<tr><td><code>snapshot</code></td><td>Full recent candles on subscribe: <code>{{"type":"snapshot","symbol":"...","timeframe":"...","data":[...]}}</code></td></tr>
<tr><td><code>kline</code></td><td>Incremental candle updates (last 2 candles): <code>{{"type":"kline","symbol":"...","timeframe":"...","data":[...]}}</code></td></tr>
<tr><td><code>pong</code></td><td>Ping response.</td></tr>
<tr><td><code>unsubscribed</code></td><td>Unsubscribe confirmation.</td></tr>
<tr><td><code>error</code></td><td>Error message: <code>{{"type":"error","message":"..."}}</code></td></tr>
</tbody>
</table>
<h6 class="mt-3">Python Client Example</h6>
<pre><code>import asyncio, json
import websockets
async def listen():
async with websockets.connect("ws://localhost:80/ws") as ws:
# Subscribe to BTC 1m candles
await ws.send(json.dumps({{
"action": "subscribe",
"symbol": "BTC/USDT:USDT",
"timeframe": "1m"
}}))
while True:
msg = json.loads(await ws.recv())
print(f"[{{msg['type']}}] {{msg.get('symbol','')}} {{msg.get('timeframe','')}}")
if msg['type'] == 'kline':
for candle in msg.get('data', []):
print(f" o={{candle['open']}} h={{candle['high']}} "
f"l={{candle['low']}} c={{candle['close']}}")
asyncio.run(listen())</code></pre>
<p class="small text-muted">Requires: <code>pip install websockets</code>. Subscribe to multiple symbol/timeframe pairs by sending additional subscribe messages.</p>
</div>
</div>
</div>
<!-- ==================== TIMEFRAMES ==================== -->
<div class="section" id="section-timeframes">
<div class="card">
<div class="card-header">Base & Derived Timeframes</div>
<div class="card-body">
<p class="small"><strong>Base</strong> timeframes are fetched directly from the exchange. <strong>Derived</strong> timeframes are synthesized on-the-fly by aggregating base candles — no extra API calls needed.</p>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Base Timeframe</th><th>Configured</th><th>Derived Timeframes Synthesized</th></tr></thead>
<tbody>{derived_rows}</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">Memory Limits</div>
<div class="card-body">
<p class="small">To bound memory usage, only a sliding window is held in RAM for high-frequency timeframes. Data beyond the window is served from CSV on disk lazily.</p>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Timeframe</th><th>Max Candles in Memory</th></tr></thead>
<tbody>{memory_rows}</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">CSV Storage</div>
<div class="card-body">
<p class="small">Data is persisted under <code>data/&lt;timeframe&gt;/</code> as one CSV file per symbol (e.g. <code>data/1m/BTC_USDT_USDT.csv</code>). Schema: <code>timestamp,datetime,open,high,low,close,volume</code>.</p>
</div>
</div>
</div>
<!-- ==================== CONFIGURATION ==================== -->
<div class="section" id="section-config">
<div class="card">
<div class="card-header">config.json</div>
<div class="card-body">
<pre><code>{config_example}</code></pre>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>exchange</code></td><td>string</td><td>CCXT exchange ID. Tested with <code>binance</code> (Binance Futures).</td></tr>
<tr><td><code>symbols</code></td><td>string[]</td><td>Trading pairs in Binance Futures format: <code>BTC/USDT:USDT</code>. Also accepts comma-separated string.</td></tr>
<tr><td><code>start_time</code></td><td>string</td><td>Global earliest backfill date, ISO 8601 UTC. Used as fallback for timeframes not listed in <code>start_time_per_tf</code>.</td></tr>
<tr><td><code>start_time_per_tf</code></td><td>object</td><td><em>Optional.</em> Per-timeframe override. Keys are timeframe strings (e.g. <code>"1m"</code>), values are ISO 8601 UTC dates. Useful for limiting high-frequency data volume (e.g. 1m from recent date, longer timeframes from earlier).</td></tr>
<tr><td><code>timeframes</code></td><td>string[]</td><td>Base timeframes to fetch. Supported: <code>1m</code>, <code>1h</code>, <code>1d</code>, <code>1w</code>.</td></tr>
<tr><td><code>data_dir</code></td><td>string</td><td>Directory for CSV storage (created if missing).</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">Environment Variables</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Variable</th><th>Default</th><th>Description</th></tr></thead>
<tbody>
<tr><td><code>CONFIG_PATH</code></td><td><code>config.json</code></td><td>Path to the JSON config file.</td></tr>
<tr><td><code>UVICORN_HOST</code></td><td><code>0.0.0.0</code></td><td>Host to bind.</td></tr>
<tr><td><code>UVICORN_PORT</code></td><td><code>80</code></td><td>Port to bind.</td></tr>
<tr><td><code>UVICORN_LOG_LEVEL</code></td><td><code>info</code></td><td>Log level: <code>debug</code>, <code>info</code>, <code>warning</code>, <code>error</code>.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ==================== EXAMPLES ==================== -->
<div class="section" id="section-examples">
<div class="card">
<div class="card-header">cURL — Fetch Last 500 5-Minute Candles</div>
<div class="card-body">
<pre><code>curl "http://localhost:80/api/candles?symbol=BTC/USDT:USDT&tf=5m&limit=500"</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">cURL — Fetch Date Range</div>
<div class="card-body">
<pre><code># 4-hour candles for ETH, JanMar 2024
curl "http://localhost:80/api/candles?symbol=ETH/USDT:USDT&tf=4h&start=1704067200000&end=1711929600000"</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">Python — requests</div>
<div class="card-body">
<pre><code>import requests
# Check service is ready
resp = requests.get("http://localhost:80/health")
meta = resp.json()
print(f"Ready: {{meta['ready']}}")
# Fetch candles
params = {{
"symbol": "BTC/USDT:USDT",
"tf": "1h",
"limit": 200,
}}
resp = requests.get("http://localhost:80/api/candles", params=params)
candles = resp.json()
for c in candles[:3]:
print(c["datetime"], c["open"], c["close"])</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">JavaScript — WebSocket</div>
<div class="card-body">
<pre><code>const ws = new WebSocket("ws://localhost:80/ws");
ws.onopen = () => {{
ws.send(JSON.stringify({{
action: "subscribe",
symbol: "BTC/USDT:USDT",
timeframe: "1m"
}}));
}};
ws.onmessage = (event) => {{
const msg = JSON.parse(event.data);
if (msg.type === "snapshot") {{
console.log("Initial data:", msg.data.length, "candles");
}} else if (msg.type === "kline") {{
console.log("Update:", msg.data);
}}
}};
// Keepalive every 30s
setInterval(() => ws.send(JSON.stringify({{action: "ping"}})), 30000);</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">JavaScript — fetch (browser)</div>
<div class="card-body">
<pre><code>const params = new URLSearchParams({{
symbol: "BTC/USDT:USDT",
tf: "5m",
limit: "500"
}});
const resp = await fetch(`/api/candles?${{params}}`);
const candles = await resp.json();
console.log(`Got ${{candles.length}} candles`);</code></pre>
</div>
</div>
</div>
<footer>
Data Provider v1.0.0 &middot; Chan Project &middot; Served from {provider.exchange_name}
</footer>
</div>
<script>
// Simple tab switcher
document.querySelectorAll('#manualTabs .nav-link').forEach(btn => {{
btn.addEventListener('click', () => {{
document.querySelectorAll('#manualTabs .nav-link').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
btn.classList.add('active');
document.getElementById('section-' + btn.dataset.section).classList.add('active');
}});
}});
</script>
</body>
</html>"""
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket): async def websocket_endpoint(ws: WebSocket):
"""WebSocket 实时 K 线推送。 """WebSocket 实时 K 线推送。
@@ -937,7 +1597,7 @@ app = build_app()
def main() -> None: def main() -> None:
"""直接运行本模块时启动 uvicorn(监听 UVICORN_HOST / UVICORN_PORT)。""" """直接运行本模块时启动 uvicorn(监听 UVICORN_HOST / UVICORN_PORT)。"""
host = os.getenv("UVICORN_HOST", "0.0.0.0") host = os.getenv("UVICORN_HOST", "0.0.0.0")
port = int(os.getenv("UVICORN_PORT", "9009")) port = int(os.getenv("UVICORN_PORT", "80"))
uvicorn.run(app, host=host, port=port, log_level=os.getenv("UVICORN_LOG_LEVEL", "info")) uvicorn.run(app, host=host, port=port, log_level=os.getenv("UVICORN_LOG_LEVEL", "info"))