Initial CoinOX instance: coin-margined options + USDT hedge.
Forked from crypto_monitor OKX instance (copy-only). Keeps standalone options, perp-options and options-options hedge; drops standalone perp entrypoints; forces coin margin; adds sim wallets; default port 5010. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared library package."""
|
||||
@@ -0,0 +1,888 @@
|
||||
"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径.
|
||||
|
||||
仅只读行情;不触及下单链路.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import statistics
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
APP_TZ = ZoneInfo("Asia/Shanghai")
|
||||
END_HOUR = 16
|
||||
EXCHANGE = "okx"
|
||||
TIMEFRAME = "1H"
|
||||
|
||||
SYMBOLS: dict[str, dict[str, str]] = {
|
||||
"eth": {
|
||||
"label": "ETH",
|
||||
"index_inst": "ETH-USD",
|
||||
"swap_inst": "ETH-USDT-SWAP",
|
||||
},
|
||||
"btc": {
|
||||
"label": "BTC",
|
||||
"index_inst": "BTC-USD",
|
||||
"swap_inst": "BTC-USDT-SWAP",
|
||||
},
|
||||
}
|
||||
|
||||
PERIOD_DAYS: dict[str, int] = {
|
||||
"1m": 30,
|
||||
"2m": 60,
|
||||
"3m": 90,
|
||||
"6m": 180,
|
||||
"1y": 365,
|
||||
}
|
||||
|
||||
OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles"
|
||||
OKX_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles"
|
||||
OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles"
|
||||
OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-candles"
|
||||
|
||||
|
||||
def normalize_symbol(raw: str) -> str:
|
||||
s = (raw or "").strip().lower()
|
||||
if s in ("eth", "ethereum"):
|
||||
return "eth"
|
||||
if s in ("btc", "bitcoin"):
|
||||
return "btc"
|
||||
raise ValueError("symbol 仅支持 eth / btc")
|
||||
|
||||
|
||||
def resolve_sample_days(period: str, custom_days: Any = None) -> int:
|
||||
p = (period or "2m").strip().lower()
|
||||
if p == "custom":
|
||||
try:
|
||||
n = int(custom_days)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("自定义天数无效") from None
|
||||
return max(7, min(400, n))
|
||||
if p not in PERIOD_DAYS:
|
||||
raise ValueError("周期无效")
|
||||
return PERIOD_DAYS[p]
|
||||
|
||||
|
||||
def window_bounds_for_settlement(
|
||||
settlement: date,
|
||||
start_hour: int,
|
||||
*,
|
||||
span_days: int = 1,
|
||||
) -> tuple[datetime, datetime]:
|
||||
"""返回 [start, end) 的本地时刻;end 为结算日 16:00.
|
||||
|
||||
span_days=1: 与现口径相同(如 26日16:00→27日16:00)
|
||||
span_days=2: 再往前推 1 天(如 25日16:00→27日16:00)
|
||||
"""
|
||||
if not (0 <= int(start_hour) <= 23):
|
||||
raise ValueError("起点须为 0-23 整点")
|
||||
span = max(1, int(span_days or 1))
|
||||
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
||||
sh = int(start_hour)
|
||||
if sh >= END_HOUR:
|
||||
prev = settlement - timedelta(days=1)
|
||||
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
|
||||
else:
|
||||
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
|
||||
if span > 1:
|
||||
start = start - timedelta(days=span - 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]:
|
||||
"""最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00)."""
|
||||
now = now or datetime.now(APP_TZ)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=APP_TZ)
|
||||
else:
|
||||
now = now.astimezone(APP_TZ)
|
||||
today = now.date()
|
||||
today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
||||
latest = today if now >= today_end else today - timedelta(days=1)
|
||||
return [latest - timedelta(days=i) for i in range(int(sample_days))]
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
try:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
|
||||
"""open_time_ms -> {o,h,l,c}."""
|
||||
m: dict[int, dict[str, float]] = {}
|
||||
for b in bars or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
ts = b.get("ts")
|
||||
if ts is None:
|
||||
ts = b.get("open_time_ms")
|
||||
try:
|
||||
ts_i = int(ts)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
o = _safe_float(b.get("o") if "o" in b else b.get("open"))
|
||||
h = _safe_float(b.get("h") if "h" in b else b.get("high"))
|
||||
l = _safe_float(b.get("l") if "l" in b else b.get("low"))
|
||||
c = _safe_float(b.get("c") if "c" in b else b.get("close"))
|
||||
if None in (o, h, l, c):
|
||||
continue
|
||||
m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)}
|
||||
return m
|
||||
|
||||
|
||||
def _ohlc_window_metrics(
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
bar_map: dict[int, dict[str, float]],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""在 [start, end) 上算开高低收与开→高/开→低/振幅/涨跌."""
|
||||
start_ms = int(start.timestamp() * 1000)
|
||||
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
|
||||
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
|
||||
if start_ms not in bar_map or last_bar_ms not in bar_map:
|
||||
return None
|
||||
opens = bar_map[start_ms]["o"]
|
||||
close = bar_map[last_bar_ms]["c"]
|
||||
hi = bar_map[start_ms]["h"]
|
||||
lo = bar_map[start_ms]["l"]
|
||||
t = start_ms
|
||||
while t <= last_bar_ms:
|
||||
b = bar_map.get(t)
|
||||
if b:
|
||||
hi = max(hi, b["h"])
|
||||
lo = min(lo, b["l"])
|
||||
t += 3600 * 1000
|
||||
up = hi - opens
|
||||
down = opens - lo
|
||||
amp = hi - lo
|
||||
change = close - opens
|
||||
return {
|
||||
"window_start": start.strftime("%Y-%m-%d %H:%M"),
|
||||
"window_end": end.strftime("%Y-%m-%d %H:%M"),
|
||||
"open": round(opens, 4),
|
||||
"high": round(hi, 4),
|
||||
"low": round(lo, 4),
|
||||
"close": round(close, 4),
|
||||
"up_points": round(up, 4),
|
||||
"down_points": round(down, 4),
|
||||
"amplitude": round(amp, 4),
|
||||
"change": round(change, 4),
|
||||
}
|
||||
|
||||
|
||||
def compute_day_row(
|
||||
settlement: date,
|
||||
start_hour: int,
|
||||
bar_map: dict[int, dict[str, float]],
|
||||
) -> Optional[dict[str, Any]]:
|
||||
start, end = window_bounds_for_settlement(settlement, start_hour, span_days=1)
|
||||
m1 = _ohlc_window_metrics(start, end, bar_map)
|
||||
if m1 is None:
|
||||
return None
|
||||
start2, end2 = window_bounds_for_settlement(settlement, start_hour, span_days=2)
|
||||
m2 = _ohlc_window_metrics(start2, end2, bar_map)
|
||||
wd = settlement.weekday() # Mon=0 … Sun=6
|
||||
is_we = wd >= 5
|
||||
row: dict[str, Any] = {
|
||||
"settlement_day": settlement.isoformat(),
|
||||
"weekday": wd,
|
||||
"weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""),
|
||||
"is_weekend": is_we,
|
||||
**m1,
|
||||
}
|
||||
if m2 is None:
|
||||
row.update(
|
||||
{
|
||||
"window2_start": start2.strftime("%Y-%m-%d %H:%M"),
|
||||
"window2_end": end2.strftime("%Y-%m-%d %H:%M"),
|
||||
"open_2d": None,
|
||||
"high_2d": None,
|
||||
"low_2d": None,
|
||||
"close_2d": None,
|
||||
"up_points_2d": None,
|
||||
"down_points_2d": None,
|
||||
"amplitude_2d": None,
|
||||
"change_2d": None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
row.update(
|
||||
{
|
||||
"window2_start": m2["window_start"],
|
||||
"window2_end": m2["window_end"],
|
||||
"open_2d": m2["open"],
|
||||
"high_2d": m2["high"],
|
||||
"low_2d": m2["low"],
|
||||
"close_2d": m2["close"],
|
||||
"up_points_2d": m2["up_points"],
|
||||
"down_points_2d": m2["down_points"],
|
||||
"amplitude_2d": m2["amplitude"],
|
||||
"change_2d": m2["change"],
|
||||
}
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def normalize_move_points(raw: Any) -> Optional[float]:
|
||||
"""对照波动点数.空/≤0 表示不做点数达标对照."""
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("波动点数须为数字") from None
|
||||
if v <= 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def normalize_weekend_filter(raw: Any) -> str:
|
||||
"""all | exclude | only;默认全部."""
|
||||
s = (str(raw) if raw is not None else "all").strip().lower()
|
||||
if s in ("", "all", "全部"):
|
||||
return "all"
|
||||
if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"):
|
||||
return "exclude"
|
||||
if s in ("only", "weekend_only", "only_weekend", "仅周末"):
|
||||
return "only"
|
||||
raise ValueError("周末筛选须为 all / exclude / only")
|
||||
|
||||
|
||||
def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]:
|
||||
mode = normalize_weekend_filter(weekend_filter)
|
||||
if mode == "all":
|
||||
return list(rows or [])
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows or []:
|
||||
is_we = bool(r.get("is_weekend"))
|
||||
if "is_weekend" not in r and r.get("settlement_day"):
|
||||
try:
|
||||
is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5
|
||||
except ValueError:
|
||||
is_we = False
|
||||
if mode == "exclude" and is_we:
|
||||
continue
|
||||
if mode == "only" and not is_we:
|
||||
continue
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def _ensure_weekend_flags(item: dict[str, Any]) -> None:
|
||||
if "is_weekend" in item:
|
||||
return
|
||||
if not item.get("settlement_day"):
|
||||
item.setdefault("weekday_label", "")
|
||||
item.setdefault("is_weekend", False)
|
||||
return
|
||||
try:
|
||||
wd = date.fromisoformat(str(item["settlement_day"])).weekday()
|
||||
item["weekday"] = wd
|
||||
item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "")
|
||||
item["is_weekend"] = wd >= 5
|
||||
except ValueError:
|
||||
item.setdefault("weekday_label", "")
|
||||
item.setdefault("is_weekend", False)
|
||||
|
||||
|
||||
def enrich_rows(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
move_points: Any = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""为日表附加周末标注,以及相对波动点数的两边达标."""
|
||||
mp = normalize_move_points(move_points)
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows or []:
|
||||
item = dict(r)
|
||||
_ensure_weekend_flags(item)
|
||||
up = float(item.get("up_points") or 0)
|
||||
down = float(item.get("down_points") or 0)
|
||||
amp = float(item.get("amplitude") or 0)
|
||||
hit_up = bool(mp is not None and up >= mp)
|
||||
hit_down = bool(mp is not None and down >= mp)
|
||||
amp_hit = bool(mp is not None and amp >= mp)
|
||||
amp2 = item.get("amplitude_2d")
|
||||
amp2_v = float(amp2) if amp2 is not None and amp2 != "" else None
|
||||
amp_hit_2d = bool(mp is not None and amp2_v is not None and amp2_v >= mp)
|
||||
item["move_points"] = mp
|
||||
item["hit_up"] = hit_up
|
||||
item["hit_down"] = hit_down
|
||||
item["hit_either"] = hit_up or hit_down
|
||||
item["hit_both"] = hit_up and hit_down
|
||||
item["amp_hit"] = amp_hit
|
||||
item["amp_hit_2d"] = amp_hit_2d
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# 兼容旧调用名
|
||||
def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]:
|
||||
return enrich_rows(rows, move_points=kwargs.get("move_points"))
|
||||
|
||||
|
||||
def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]:
|
||||
"""波动点数达标汇总:开→高/开→低两边."""
|
||||
mp = float(move_points)
|
||||
if mp <= 0:
|
||||
raise ValueError("波动点数须 > 0")
|
||||
work = enrich_rows(rows, move_points=mp)
|
||||
n = len(work)
|
||||
empty = {
|
||||
"move_points": round(mp, 4),
|
||||
"sample_count": n,
|
||||
"up_hit_days": 0,
|
||||
"up_hit_ratio": None,
|
||||
"down_hit_days": 0,
|
||||
"down_hit_ratio": None,
|
||||
"either_hit_days": 0,
|
||||
"either_hit_ratio": None,
|
||||
"both_hit_days": 0,
|
||||
"both_hit_ratio": None,
|
||||
"amp_hit_days": 0,
|
||||
"amp_hit_ratio": None,
|
||||
"amp_2d_hit_days": 0,
|
||||
"amp_2d_hit_ratio": None,
|
||||
"abs_change_hit_days": 0,
|
||||
"abs_change_hit_ratio": None,
|
||||
}
|
||||
if n <= 0:
|
||||
return empty
|
||||
up_hit = sum(1 for r in work if r.get("hit_up"))
|
||||
down_hit = sum(1 for r in work if r.get("hit_down"))
|
||||
either = sum(1 for r in work if r.get("hit_either"))
|
||||
both = sum(1 for r in work if r.get("hit_both"))
|
||||
amp_hit = sum(1 for r in work if r.get("amp_hit"))
|
||||
amp2_rows = [r for r in work if r.get("amplitude_2d") is not None]
|
||||
amp2_hit = sum(1 for r in work if r.get("amp_hit_2d"))
|
||||
n2 = len(amp2_rows)
|
||||
abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp)
|
||||
empty.update(
|
||||
{
|
||||
"up_hit_days": up_hit,
|
||||
"up_hit_ratio": round(up_hit / n, 4),
|
||||
"down_hit_days": down_hit,
|
||||
"down_hit_ratio": round(down_hit / n, 4),
|
||||
"either_hit_days": either,
|
||||
"either_hit_ratio": round(either / n, 4),
|
||||
"both_hit_days": both,
|
||||
"both_hit_ratio": round(both / n, 4),
|
||||
"amp_hit_days": amp_hit,
|
||||
"amp_hit_ratio": round(amp_hit / n, 4),
|
||||
"amp_2d_hit_days": amp2_hit,
|
||||
"amp_2d_hit_ratio": round(amp2_hit / n2, 4) if n2 else None,
|
||||
"abs_change_hit_days": abs_hit,
|
||||
"abs_change_hit_ratio": round(abs_hit / n, 4),
|
||||
}
|
||||
)
|
||||
return empty
|
||||
|
||||
|
||||
def summarize_rows(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
move_points: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
mp = normalize_move_points(move_points)
|
||||
empty_2d = {
|
||||
"max_amplitude_2d": None,
|
||||
"max_amplitude_2d_day": None,
|
||||
"avg_amplitude_2d": None,
|
||||
"median_amplitude_2d": None,
|
||||
}
|
||||
if not rows:
|
||||
out = {
|
||||
"sample_count": 0,
|
||||
"max_amplitude": None,
|
||||
"max_amplitude_day": None,
|
||||
"avg_amplitude": None,
|
||||
"median_amplitude": None,
|
||||
"max_up_points": None,
|
||||
"avg_up_points": None,
|
||||
"max_down_points": None,
|
||||
"avg_down_points": None,
|
||||
"up_day_ratio": None,
|
||||
"down_day_ratio": None,
|
||||
**empty_2d,
|
||||
"move_points_stats": None,
|
||||
}
|
||||
if mp is not None:
|
||||
out["move_points_stats"] = move_points_stats([], mp)
|
||||
return out
|
||||
amps = [float(r["amplitude"]) for r in rows]
|
||||
ups = [float(r["up_points"]) for r in rows]
|
||||
downs = [float(r["down_points"]) for r in rows]
|
||||
max_amp = max(amps)
|
||||
max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp)
|
||||
up_days = sum(1 for r in rows if float(r["change"]) > 0)
|
||||
down_days = sum(1 for r in rows if float(r["change"]) < 0)
|
||||
n = len(rows)
|
||||
amps2 = [float(r["amplitude_2d"]) for r in rows if r.get("amplitude_2d") is not None]
|
||||
out: dict[str, Any] = {
|
||||
"sample_count": n,
|
||||
"max_amplitude": round(max_amp, 4),
|
||||
"max_amplitude_day": max_amp_day,
|
||||
"avg_amplitude": round(statistics.fmean(amps), 4),
|
||||
"median_amplitude": round(statistics.median(amps), 4),
|
||||
"max_up_points": round(max(ups), 4),
|
||||
"avg_up_points": round(statistics.fmean(ups), 4),
|
||||
"max_down_points": round(max(downs), 4),
|
||||
"avg_down_points": round(statistics.fmean(downs), 4),
|
||||
"up_day_ratio": round(up_days / n, 4),
|
||||
"down_day_ratio": round(down_days / n, 4),
|
||||
**empty_2d,
|
||||
"move_points_stats": None,
|
||||
}
|
||||
if amps2:
|
||||
max_a2 = max(amps2)
|
||||
out["max_amplitude_2d"] = round(max_a2, 4)
|
||||
out["max_amplitude_2d_day"] = next(
|
||||
r["settlement_day"] for r in rows if r.get("amplitude_2d") is not None and float(r["amplitude_2d"]) == max_a2
|
||||
)
|
||||
out["avg_amplitude_2d"] = round(statistics.fmean(amps2), 4)
|
||||
out["median_amplitude_2d"] = round(statistics.median(amps2), 4)
|
||||
if mp is not None:
|
||||
out["move_points_stats"] = move_points_stats(rows, mp)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]:
|
||||
if not row or len(row) < 5:
|
||||
return None
|
||||
try:
|
||||
ts = int(row[0])
|
||||
o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
return {"ts": ts, "o": o, "h": h, "l": l, "c": c}
|
||||
|
||||
|
||||
def _okx_get_json(
|
||||
client: httpx.Client,
|
||||
url: str,
|
||||
params: dict[str, str],
|
||||
*,
|
||||
retries: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""GET OKX 公共行情;遇 429 指数退避重试."""
|
||||
last_err: Optional[BaseException] = None
|
||||
for attempt in range(max(1, int(retries))):
|
||||
try:
|
||||
r = client.get(url, params=params)
|
||||
if r.status_code == 429:
|
||||
wait = min(12.0, 0.7 * (2**attempt))
|
||||
time.sleep(wait)
|
||||
last_err = httpx.HTTPStatusError(
|
||||
f"429 Too Many Requests for url '{r.url}'",
|
||||
request=r.request,
|
||||
response=r,
|
||||
)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError("OKX 返回非对象 JSON")
|
||||
return body
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code if exc.response is not None else None
|
||||
if status == 429 and attempt + 1 < retries:
|
||||
wait = min(12.0, 0.7 * (2**attempt))
|
||||
time.sleep(wait)
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
except httpx.TransportError as exc:
|
||||
if attempt + 1 < retries:
|
||||
time.sleep(min(8.0, 0.5 * (2**attempt)))
|
||||
last_err = exc
|
||||
continue
|
||||
raise
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise RuntimeError("OKX 请求失败")
|
||||
|
||||
|
||||
def fetch_okx_candles(
|
||||
*,
|
||||
url: str,
|
||||
inst_id: str,
|
||||
since_ms: int,
|
||||
until_ms: int,
|
||||
bar: str = "1H",
|
||||
client: Optional[httpx.Client] = None,
|
||||
timeout: float = 30.0,
|
||||
history_url: Optional[str] = None,
|
||||
max_pages: int = 200,
|
||||
page_pause_sec: float = 0.12,
|
||||
history_page_pause_sec: float = 0.22,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).
|
||||
|
||||
OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉.
|
||||
分页带间隔,429 自动退避重试.
|
||||
"""
|
||||
own = client is None
|
||||
client = client or httpx.Client(
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
headers={"User-Agent": "crypto_monitor-amp-stats/1.0"},
|
||||
)
|
||||
try:
|
||||
out: dict[int, dict[str, Any]] = {}
|
||||
after: Optional[str] = None
|
||||
active_url = url
|
||||
switched_history = False
|
||||
for page_i in range(max(20, int(max_pages))):
|
||||
if page_i > 0:
|
||||
pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec
|
||||
if pause > 0:
|
||||
time.sleep(pause)
|
||||
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
|
||||
if after:
|
||||
params["after"] = after
|
||||
body = _okx_get_json(client, active_url, params)
|
||||
if str(body.get("code") or "") not in ("0", "0.0", ""):
|
||||
raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}")
|
||||
data = body.get("data") or []
|
||||
if not data:
|
||||
# 近期接口到头 → 切历史端点再试
|
||||
if history_url and not switched_history and after is not None:
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
continue
|
||||
break
|
||||
oldest_ts = None
|
||||
for row in data:
|
||||
parsed = _parse_okx_candle_row(row)
|
||||
if not parsed:
|
||||
continue
|
||||
ts = int(parsed["ts"])
|
||||
oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts)
|
||||
if ts < since_ms - 3600 * 1000:
|
||||
continue
|
||||
if ts > until_ms + 3600 * 1000:
|
||||
continue
|
||||
out[ts] = parsed
|
||||
if oldest_ts is None:
|
||||
break
|
||||
if oldest_ts <= since_ms:
|
||||
break
|
||||
# 无新进度时避免死循环
|
||||
if after is not None and str(oldest_ts) == after:
|
||||
if history_url and not switched_history:
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
continue
|
||||
break
|
||||
after = str(oldest_ts)
|
||||
# 近期接口返回变少且仍未覆盖 since → 切历史
|
||||
if (
|
||||
history_url
|
||||
and not switched_history
|
||||
and len(data) < 100
|
||||
and oldest_ts > since_ms
|
||||
):
|
||||
active_url = history_url
|
||||
switched_history = True
|
||||
time.sleep(max(history_page_pause_sec, 0.35))
|
||||
return [out[k] for k in sorted(out.keys())]
|
||||
finally:
|
||||
if own:
|
||||
client.close()
|
||||
|
||||
|
||||
def fetch_symbol_bars(
|
||||
symbol: str,
|
||||
*,
|
||||
since_ms: int,
|
||||
until_ms: int,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
) -> tuple[list[dict[str, Any]], str, str]:
|
||||
"""返回 (bars, price_source_label, inst_id)."""
|
||||
key = normalize_symbol(symbol)
|
||||
meta = SYMBOLS[key]
|
||||
if fetch_fn:
|
||||
bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms)
|
||||
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
||||
|
||||
index_err: Optional[BaseException] = None
|
||||
try:
|
||||
bars = fetch_okx_candles(
|
||||
url=OKX_INDEX_CANDLES,
|
||||
history_url=OKX_HISTORY_INDEX_CANDLES,
|
||||
inst_id=meta["index_inst"],
|
||||
since_ms=since_ms,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
if bars:
|
||||
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
||||
except Exception as exc:
|
||||
index_err = exc
|
||||
# 指数侧已触发限频时先冷却,再降级永续,避免连环 429
|
||||
time.sleep(1.2)
|
||||
|
||||
try:
|
||||
bars = fetch_okx_candles(
|
||||
url=OKX_SWAP_CANDLES,
|
||||
history_url=OKX_HISTORY_SWAP_CANDLES,
|
||||
inst_id=meta["swap_inst"],
|
||||
since_ms=since_ms,
|
||||
until_ms=until_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = f"index={index_err}; swap={exc}" if index_err else str(exc)
|
||||
raise RuntimeError(f"OKX K线拉取失败({detail})") from exc
|
||||
if not bars:
|
||||
detail = f"index={index_err}" if index_err else "empty"
|
||||
raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})")
|
||||
return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"]
|
||||
|
||||
|
||||
def compute_amp_stats(
|
||||
*,
|
||||
symbol: str = "eth",
|
||||
start_hour: int = 16,
|
||||
period: str = "2m",
|
||||
custom_days: Any = None,
|
||||
move_points: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
now: Optional[datetime] = None,
|
||||
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
key = normalize_symbol(symbol)
|
||||
sh = int(start_hour)
|
||||
if sh < 0 or sh > 23:
|
||||
raise ValueError("起点须为 0-23 整点")
|
||||
mp = normalize_move_points(move_points)
|
||||
we_mode = normalize_weekend_filter(weekend_filter)
|
||||
sample_days = resolve_sample_days(period, custom_days)
|
||||
settlements = list_settlement_dates(sample_days=sample_days, now=now)
|
||||
if not settlements:
|
||||
raise RuntimeError("无可用结算日")
|
||||
# 最远窗起点(含两日振幅,多拉 1 天)
|
||||
oldest = settlements[-1]
|
||||
newest = settlements[0]
|
||||
start0, _ = window_bounds_for_settlement(oldest, sh, span_days=2)
|
||||
_, end1 = window_bounds_for_settlement(newest, sh, span_days=1)
|
||||
since_ms = int(start0.timestamp() * 1000)
|
||||
until_ms = int(end1.timestamp() * 1000)
|
||||
bars, price_source, inst_id = fetch_symbol_bars(
|
||||
key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn
|
||||
)
|
||||
bar_map = bars_to_map(bars)
|
||||
rows_all: list[dict[str, Any]] = []
|
||||
missing: list[str] = []
|
||||
for d in settlements:
|
||||
row = compute_day_row(d, sh, bar_map)
|
||||
if row is None:
|
||||
missing.append(d.isoformat())
|
||||
continue
|
||||
rows_all.append(row)
|
||||
return build_amp_result(
|
||||
rows_all=rows_all,
|
||||
symbol_key=key,
|
||||
start_hour=sh,
|
||||
period=period,
|
||||
sample_days=sample_days,
|
||||
move_points=mp,
|
||||
weekend_filter=we_mode,
|
||||
price_source=price_source,
|
||||
inst_id=inst_id,
|
||||
missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def build_amp_result(
|
||||
*,
|
||||
rows_all: list[dict[str, Any]],
|
||||
symbol_key: str,
|
||||
start_hour: int,
|
||||
period: str,
|
||||
sample_days: int,
|
||||
move_points: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
price_source: str = "",
|
||||
inst_id: str = "",
|
||||
missing: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
mp = normalize_move_points(move_points)
|
||||
we_mode = normalize_weekend_filter(weekend_filter)
|
||||
filtered = filter_weekend_rows(rows_all, we_mode)
|
||||
rows = enrich_rows(filtered, move_points=mp)
|
||||
summary = summarize_rows(rows, move_points=mp)
|
||||
if period == "custom" or str(period).startswith("custom:"):
|
||||
period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}"
|
||||
else:
|
||||
period_label = str(period)
|
||||
miss = missing or []
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange": EXCHANGE,
|
||||
"symbol": symbol_key,
|
||||
"symbol_label": SYMBOLS[symbol_key]["label"],
|
||||
"start_hour": start_hour,
|
||||
"end_hour": END_HOUR,
|
||||
"period": period_label,
|
||||
"sample_days_requested": sample_days,
|
||||
"move_points": mp,
|
||||
"weekend_filter": we_mode,
|
||||
"timeframe": TIMEFRAME,
|
||||
"price_source": price_source,
|
||||
"inst_id": inst_id,
|
||||
"timezone": "Asia/Shanghai",
|
||||
"rows_all": rows_all,
|
||||
"rows": rows,
|
||||
"summary": summary,
|
||||
"missing_days": miss[:30],
|
||||
"missing_count": len(miss),
|
||||
}
|
||||
|
||||
|
||||
def reframe_amp_stats(
|
||||
*,
|
||||
rows_all: list[dict[str, Any]],
|
||||
symbol: str = "eth",
|
||||
start_hour: int = 16,
|
||||
period: str = "2m",
|
||||
sample_days: int = 60,
|
||||
move_points: Any = None,
|
||||
weekend_filter: Any = "all",
|
||||
price_source: str = "",
|
||||
inst_id: str = "",
|
||||
missing: Optional[list[str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""已有日表上改周末/波动点数,不拉 K 线."""
|
||||
key = normalize_symbol(symbol)
|
||||
return build_amp_result(
|
||||
rows_all=list(rows_all or []),
|
||||
symbol_key=key,
|
||||
start_hour=int(start_hour),
|
||||
period=period,
|
||||
sample_days=int(sample_days or 60),
|
||||
move_points=move_points,
|
||||
weekend_filter=weekend_filter,
|
||||
price_source=price_source,
|
||||
inst_id=inst_id,
|
||||
missing=missing,
|
||||
)
|
||||
|
||||
|
||||
def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||
page = max(1, int(page or 1))
|
||||
page_size = max(5, min(100, int(page_size or 20)))
|
||||
total = len(rows)
|
||||
start = (page - 1) * page_size
|
||||
chunk = rows[start : start + page_size]
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": max(1, (total + page_size - 1) // page_size) if total else 1,
|
||||
"rows": chunk,
|
||||
}
|
||||
|
||||
|
||||
def build_export_csv(payload: dict[str, Any]) -> str:
|
||||
buf = io.StringIO()
|
||||
# Excel 友好 BOM
|
||||
buf.write("\ufeff")
|
||||
w = csv.writer(buf)
|
||||
s = payload.get("summary") or {}
|
||||
w.writerow(["【统计摘要】"])
|
||||
w.writerow(["交易所", payload.get("exchange")])
|
||||
w.writerow(["标的", payload.get("symbol_label")])
|
||||
w.writerow(["价源", payload.get("price_source")])
|
||||
w.writerow(["起点整点", f"{payload.get('start_hour')}:00"])
|
||||
w.writerow(["终点", f"{payload.get('end_hour')}:00"])
|
||||
w.writerow(["周期", payload.get("period")])
|
||||
w.writerow(["周末筛选", payload.get("weekend_filter")])
|
||||
w.writerow(["样本数", s.get("sample_count")])
|
||||
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")])
|
||||
w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")])
|
||||
w.writerow(["两日最大振幅", s.get("max_amplitude_2d"), "日期", s.get("max_amplitude_2d_day")])
|
||||
w.writerow(["两日振幅均值", s.get("avg_amplitude_2d"), "中位数", s.get("median_amplitude_2d")])
|
||||
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
|
||||
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
|
||||
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
|
||||
mp = s.get("move_points_stats") or {}
|
||||
if mp:
|
||||
w.writerow([])
|
||||
w.writerow(["【波动点数·振幅占比】", mp.get("move_points")])
|
||||
w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")])
|
||||
w.writerow(["两日振幅≥点数天数", mp.get("amp_2d_hit_days"), "占比", mp.get("amp_2d_hit_ratio")])
|
||||
w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")])
|
||||
w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")])
|
||||
w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")])
|
||||
w.writerow([])
|
||||
w.writerow(["【日表明细】"])
|
||||
w.writerow(
|
||||
[
|
||||
"结算日",
|
||||
"星期",
|
||||
"周末",
|
||||
"窗起点",
|
||||
"窗终点",
|
||||
"开盘",
|
||||
"最高",
|
||||
"最低",
|
||||
"收盘",
|
||||
"开→高",
|
||||
"开→低",
|
||||
"振幅",
|
||||
"涨跌值",
|
||||
"两日窗起点",
|
||||
"两日窗终点",
|
||||
"两日振幅",
|
||||
"两日开→高",
|
||||
"两日开→低",
|
||||
"对照点数",
|
||||
"振幅达标",
|
||||
"两日振幅达标",
|
||||
]
|
||||
)
|
||||
for r in payload.get("rows") or []:
|
||||
w.writerow(
|
||||
[
|
||||
r.get("settlement_day"),
|
||||
r.get("weekday_label") or "",
|
||||
"是" if r.get("is_weekend") else "否",
|
||||
r.get("window_start"),
|
||||
r.get("window_end"),
|
||||
r.get("open"),
|
||||
r.get("high"),
|
||||
r.get("low"),
|
||||
r.get("close"),
|
||||
r.get("up_points"),
|
||||
r.get("down_points"),
|
||||
r.get("amplitude"),
|
||||
r.get("change"),
|
||||
r.get("window2_start"),
|
||||
r.get("window2_end"),
|
||||
r.get("amplitude_2d"),
|
||||
r.get("up_points_2d"),
|
||||
r.get("down_points_2d"),
|
||||
r.get("move_points") if r.get("move_points") is not None else "",
|
||||
"是" if r.get("amp_hit") else ("否" if r.get("move_points") is not None else ""),
|
||||
"是" if r.get("amp_hit_2d") else ("否" if r.get("move_points") is not None and r.get("amplitude_2d") is not None else ""),
|
||||
]
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def export_filename(payload: dict[str, Any]) -> str:
|
||||
sym = (payload.get("symbol") or "eth").lower()
|
||||
sh = int(payload.get("start_hour") or 16)
|
||||
period = str(payload.get("period") or "2m").replace(":", "")
|
||||
day = datetime.now(APP_TZ).strftime("%Y%m%d")
|
||||
return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"
|
||||
@@ -0,0 +1,36 @@
|
||||
"""中控调用实例 API 时的鉴权(Flask request 头 X-Hub-Token).SSO 见 hub_sso.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from lib.hub.hub_sso import (
|
||||
HUB_SSO_TTL_SEC,
|
||||
hub_bridge_token,
|
||||
mint_hub_sso_token,
|
||||
safe_next_path,
|
||||
verify_hub_sso_token,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HUB_SSO_TTL_SEC",
|
||||
"hub_bridge_token",
|
||||
"mint_hub_sso_token",
|
||||
"safe_next_path",
|
||||
"verify_hub_sso_token",
|
||||
"request_allowed",
|
||||
]
|
||||
|
||||
|
||||
def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool:
|
||||
if auth_disabled or session_logged_in:
|
||||
return True
|
||||
tok = hub_bridge_token()
|
||||
if not tok:
|
||||
return False
|
||||
try:
|
||||
from flask import request
|
||||
except ImportError:
|
||||
return False
|
||||
if request.headers.get("X-Hub-Token") == tok:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,447 @@
|
||||
"""中控备份与恢复:三所 SQLite,K 线库,env,hub JSON."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir
|
||||
|
||||
HUB_DIR = manual_trading_hub_dir()
|
||||
TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
|
||||
EXCHANGE_DIRS: list[tuple[str, str]] = [
|
||||
("binance", "crypto_monitor_binance"),
|
||||
("okx", "crypto_monitor_okx"),
|
||||
("gate", "crypto_monitor_gate"),
|
||||
]
|
||||
|
||||
HUB_JSON_FILES = (
|
||||
"hub_settings.json",
|
||||
"hub_fund_history.json",
|
||||
"hub_ai_summaries.json",
|
||||
"hub_ai_chat.json",
|
||||
"hub_supervisor_state.json",
|
||||
)
|
||||
|
||||
HUB_DATA_FILES = (
|
||||
"hub_kline.db",
|
||||
"hub_symbol_archive.db",
|
||||
"hub_entry_plans.db",
|
||||
"hub_macro_calendar.db",
|
||||
"hub_volume_rank.json",
|
||||
"hub_divergence_scan.json",
|
||||
)
|
||||
|
||||
DEFAULT_BACKUP_SETTINGS = {
|
||||
"auto_enabled": True,
|
||||
"auto_hour": 0,
|
||||
"retention_days": 30,
|
||||
"include_env": True,
|
||||
"include_exchange_images": False,
|
||||
"backup_root": "",
|
||||
}
|
||||
|
||||
BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json"
|
||||
|
||||
|
||||
def normalize_backup_settings(raw: dict | None) -> dict:
|
||||
out = dict(DEFAULT_BACKUP_SETTINGS)
|
||||
if isinstance(raw, dict):
|
||||
for key in DEFAULT_BACKUP_SETTINGS:
|
||||
if key in raw:
|
||||
out[key] = raw[key]
|
||||
try:
|
||||
out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0))))
|
||||
except (TypeError, ValueError):
|
||||
out["auto_hour"] = 0
|
||||
try:
|
||||
out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30))))
|
||||
except (TypeError, ValueError):
|
||||
out["retention_days"] = 30
|
||||
out["auto_enabled"] = bool(out.get("auto_enabled"))
|
||||
out["include_env"] = bool(out.get("include_env", True))
|
||||
out["include_exchange_images"] = bool(out.get("include_exchange_images"))
|
||||
out["backup_root"] = str(out.get("backup_root") or "").strip()
|
||||
return out
|
||||
|
||||
|
||||
def backup_root(settings: dict | None = None) -> Path:
|
||||
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||
raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip()
|
||||
if not raw:
|
||||
raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip()
|
||||
root = Path(raw).expanduser()
|
||||
if not root.is_absolute():
|
||||
root = REPO_ROOT / root
|
||||
portal = root / "crypto_monitor_portal"
|
||||
portal.mkdir(parents=True, exist_ok=True)
|
||||
return portal
|
||||
|
||||
|
||||
def _now_local() -> datetime:
|
||||
try:
|
||||
return datetime.now(ZoneInfo(TZ_NAME))
|
||||
except Exception:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _read_env_var(env_path: Path, key: str, default: str = "") -> str:
|
||||
if not env_path.is_file():
|
||||
return default
|
||||
try:
|
||||
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or raw.startswith("#") or "=" not in raw:
|
||||
continue
|
||||
k, v = raw.split("=", 1)
|
||||
if k.strip() == key:
|
||||
return v.strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _resolve_project_path(project_dir: Path, rel: str) -> Path:
|
||||
p = Path(rel or "")
|
||||
if p.is_absolute():
|
||||
return p
|
||||
return project_dir / p
|
||||
|
||||
|
||||
def _load_backup_state() -> dict:
|
||||
if not BACKUP_STATE_PATH.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_backup_state(state: dict) -> None:
|
||||
BACKUP_STATE_PATH.write_text(
|
||||
json.dumps(state, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _safe_archive_name(name: str) -> bool:
|
||||
return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or ""))
|
||||
|
||||
|
||||
def _collect_targets(
|
||||
*,
|
||||
include_env: bool,
|
||||
include_exchange_images: bool,
|
||||
) -> list[tuple[str, Path, str]]:
|
||||
"""Return list of (archive_rel_path, source_path, kind)."""
|
||||
items: list[tuple[str, Path, str]] = []
|
||||
|
||||
if include_env:
|
||||
hub_env = HUB_DIR / ".env"
|
||||
if hub_env.is_file():
|
||||
items.append(("hub/.env", hub_env, "env"))
|
||||
|
||||
for name in HUB_JSON_FILES:
|
||||
src = HUB_DIR / name
|
||||
if src.is_file():
|
||||
items.append((f"hub/{name}", src, "json"))
|
||||
|
||||
data_dir = hub_data_dir()
|
||||
for name in HUB_DATA_FILES:
|
||||
src = data_dir / name
|
||||
if src.is_file():
|
||||
items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json"))
|
||||
|
||||
for key, dirname in EXCHANGE_DIRS:
|
||||
proj = REPO_ROOT / dirname
|
||||
prefix = dirname
|
||||
env_path = proj / ".env"
|
||||
db_rel = "crypto.db"
|
||||
upload_rel = "static/images"
|
||||
if env_path.is_file():
|
||||
db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db"
|
||||
upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images"
|
||||
if include_env:
|
||||
items.append((f"{prefix}/.env", env_path, "env"))
|
||||
db_path = _resolve_project_path(proj, db_rel)
|
||||
if db_path.is_file():
|
||||
items.append((f"{prefix}/{db_rel}", db_path, "sqlite"))
|
||||
if include_exchange_images:
|
||||
img_dir = _resolve_project_path(proj, upload_rel)
|
||||
if img_dir.is_dir():
|
||||
for fp in sorted(img_dir.rglob("*")):
|
||||
if fp.is_file():
|
||||
rel = fp.relative_to(proj).as_posix()
|
||||
items.append((f"{prefix}/{rel}", fp, "image"))
|
||||
return items
|
||||
|
||||
|
||||
def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None:
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"timezone": TZ_NAME,
|
||||
"trigger": trigger,
|
||||
"repo_root": str(REPO_ROOT),
|
||||
"files": files,
|
||||
}
|
||||
(staging / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def run_backup(
|
||||
*,
|
||||
trigger: str = "manual",
|
||||
settings: dict | None = None,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||
root = backup_root(settings)
|
||||
ts = _now_local().strftime("%Y-%m-%d_%H%M%S")
|
||||
archive_name = f"backup_{ts}.zip"
|
||||
archive_path = root / archive_name
|
||||
|
||||
def log(msg: str) -> None:
|
||||
if log_fn:
|
||||
log_fn(msg)
|
||||
|
||||
targets = _collect_targets(
|
||||
include_env=cfg["include_env"],
|
||||
include_exchange_images=cfg["include_exchange_images"],
|
||||
)
|
||||
if not targets:
|
||||
return {"ok": False, "error": "没有可备份的文件"}
|
||||
|
||||
file_meta: list[dict] = []
|
||||
with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp:
|
||||
staging = Path(tmp)
|
||||
for arc_rel, src, kind in targets:
|
||||
dest = staging / arc_rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
file_meta.append(
|
||||
{
|
||||
"path": arc_rel.replace("\\", "/"),
|
||||
"size": src.stat().st_size,
|
||||
"kind": kind,
|
||||
}
|
||||
)
|
||||
_write_manifest(staging, trigger, file_meta)
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for fp in sorted(staging.rglob("*")):
|
||||
if fp.is_file():
|
||||
zf.write(fp, fp.relative_to(staging).as_posix())
|
||||
|
||||
size = archive_path.stat().st_size
|
||||
prune_old_backups(root, cfg["retention_days"])
|
||||
state = _load_backup_state()
|
||||
if trigger == "auto":
|
||||
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
||||
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
state["last_backup_file"] = archive_name
|
||||
state["last_trigger"] = trigger
|
||||
_save_backup_state(state)
|
||||
log(f"backup written: {archive_path}")
|
||||
return {
|
||||
"ok": True,
|
||||
"file": archive_name,
|
||||
"path": str(archive_path),
|
||||
"size": size,
|
||||
"file_count": len(file_meta),
|
||||
"trigger": trigger,
|
||||
}
|
||||
|
||||
|
||||
def prune_old_backups(root: Path, retention_days: int) -> int:
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
cutoff = _now_local() - timedelta(days=max(1, retention_days))
|
||||
removed = 0
|
||||
for fp in root.glob("backup_*.zip"):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo)
|
||||
except Exception:
|
||||
continue
|
||||
if mtime < cutoff:
|
||||
fp.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def list_backups(settings: dict | None = None) -> list[dict[str, Any]]:
|
||||
root = backup_root(settings)
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not root.is_dir():
|
||||
return rows
|
||||
for fp in sorted(root.glob("backup_*.zip"), reverse=True):
|
||||
try:
|
||||
st = fp.stat()
|
||||
except OSError:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": fp.name,
|
||||
"size": st.st_size,
|
||||
"modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def backup_status(settings: dict | None = None) -> dict[str, Any]:
|
||||
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||
state = _load_backup_state()
|
||||
root = backup_root(settings)
|
||||
return {
|
||||
"ok": True,
|
||||
"settings": cfg,
|
||||
"backup_root": str(root),
|
||||
"state": state,
|
||||
"backups": list_backups(settings)[:50],
|
||||
"timezone": TZ_NAME,
|
||||
}
|
||||
|
||||
|
||||
def _pm2_restart_all() -> dict[str, Any]:
|
||||
if os.name != "posix":
|
||||
return {"ok": False, "skipped": True, "reason": "non-posix"}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pm2", "restart", "all"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": (proc.stdout or "")[-2000:],
|
||||
"stderr": (proc.stderr or "")[-2000:],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
archive_path: Path,
|
||||
*,
|
||||
settings: dict | None = None,
|
||||
pre_backup: bool = True,
|
||||
restart_pm2: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not archive_path.is_file():
|
||||
return {"ok": False, "error": "备份文件不存在"}
|
||||
|
||||
pre = None
|
||||
if pre_backup:
|
||||
pre = run_backup(trigger="pre_restore", settings=settings)
|
||||
|
||||
restored: list[str] = []
|
||||
skipped: list[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp:
|
||||
extract_dir = Path(tmp)
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
zf.extractall(extract_dir)
|
||||
manifest_path = extract_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return {"ok": False, "error": "无效的备份包:缺少 manifest.json"}
|
||||
|
||||
for fp in extract_dir.rglob("*"):
|
||||
if not fp.is_file() or fp.name == "manifest.json":
|
||||
continue
|
||||
rel = fp.relative_to(extract_dir).as_posix()
|
||||
parts = Path(rel).parts
|
||||
if parts[0] == "hub":
|
||||
if len(parts) >= 3 and parts[1] == "data":
|
||||
dest = hub_data_dir() / parts[-1]
|
||||
else:
|
||||
dest = HUB_DIR.joinpath(*parts[1:])
|
||||
else:
|
||||
matched = False
|
||||
for _key, dirname in EXCHANGE_DIRS:
|
||||
if rel.startswith(dirname + "/"):
|
||||
dest = REPO_ROOT / rel
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
skipped.append(rel)
|
||||
continue
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(fp, dest)
|
||||
restored.append(rel)
|
||||
|
||||
pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True}
|
||||
state = _load_backup_state()
|
||||
state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
state["last_restore_from"] = archive_path.name
|
||||
_save_backup_state(state)
|
||||
return {
|
||||
"ok": True,
|
||||
"restored": restored,
|
||||
"skipped": skipped,
|
||||
"pre_backup": pre,
|
||||
"pm2": pm2,
|
||||
}
|
||||
|
||||
|
||||
def restore_backup_upload(
|
||||
content: bytes,
|
||||
filename: str,
|
||||
*,
|
||||
settings: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not content:
|
||||
return {"ok": False, "error": "空文件"}
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if suffix != ".zip":
|
||||
return {"ok": False, "error": "仅支持 .zip 备份包"}
|
||||
with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf:
|
||||
tf.write(content)
|
||||
temp_path = Path(tf.name)
|
||||
try:
|
||||
return restore_backup_archive(temp_path, settings=settings)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]:
|
||||
if not _safe_archive_name(name):
|
||||
return None
|
||||
fp = backup_root(settings) / name
|
||||
if fp.is_file():
|
||||
return fp
|
||||
return None
|
||||
|
||||
|
||||
def should_run_auto_backup(settings: dict) -> bool:
|
||||
cfg = normalize_backup_settings(settings.get("backup"))
|
||||
if not cfg.get("auto_enabled"):
|
||||
return False
|
||||
now = _now_local()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
state = _load_backup_state()
|
||||
if state.get("last_auto_day") == today:
|
||||
return False
|
||||
if now.hour < int(cfg.get("auto_hour", 0)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def mark_auto_backup_done() -> None:
|
||||
state = _load_backup_state()
|
||||
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
||||
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
_save_backup_state(state)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
from lib.strategy.strategy_roll_lib import max_roll_legs
|
||||
from lib.strategy.strategy_trend_lib import (
|
||||
build_trend_preview_level_rows,
|
||||
calc_risk_fraction,
|
||||
compute_trend_plan_core,
|
||||
validate_trend_bounds,
|
||||
)
|
||||
|
||||
DEFAULT_DCA_LEGS = 5
|
||||
MARGIN_BUFFER = 0.95
|
||||
|
||||
|
||||
def _resolve_market(
|
||||
exchange_id: str,
|
||||
base: str,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[Callable[[float], Optional[float]]], Optional[str]]:
|
||||
from lib.hub.hub_calculator_market_lib import get_calculator_market, make_amount_precise_fn_from_market
|
||||
|
||||
market, err = get_calculator_market(exchange_id, base)
|
||||
if err or not market:
|
||||
return None, None, err or "无法解析合约"
|
||||
amount_precise = make_amount_precise_fn_from_market(market)
|
||||
return market, amount_precise, None
|
||||
|
||||
|
||||
def calc_trend_calculator(
|
||||
*,
|
||||
direction: str,
|
||||
capital_usdt: float,
|
||||
risk_percent: float,
|
||||
leverage: int,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
add_upper: float,
|
||||
take_profit: float,
|
||||
dca_legs: int = DEFAULT_DCA_LEGS,
|
||||
exchange_id: str = "0",
|
||||
base: str = "ETH",
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
market, amount_precise, merr = _resolve_market(exchange_id, base)
|
||||
if merr or not market or not amount_precise:
|
||||
return None, merr or "无法解析合约"
|
||||
contract_size = float(market.get("contract_size") or 1.0)
|
||||
exchange_symbol = market["exchange_symbol"]
|
||||
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return None, "方向须为 long 或 short"
|
||||
try:
|
||||
capital = float(capital_usdt)
|
||||
rp = float(risk_percent)
|
||||
lev = int(leverage)
|
||||
entry = float(entry_price)
|
||||
sl = float(stop_loss)
|
||||
upper = float(add_upper)
|
||||
tp = float(take_profit)
|
||||
legs = max(1, int(dca_legs))
|
||||
cs = float(contract_size) if contract_size else 1.0
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if capital <= 0 or rp <= 0 or lev <= 0 or entry <= 0 or sl <= 0 or upper <= 0 or tp <= 0:
|
||||
return None, "资金,风险,杠杆与价格须大于 0"
|
||||
|
||||
bound_err = validate_trend_bounds(direction, sl, upper)
|
||||
if bound_err:
|
||||
return None, bound_err
|
||||
|
||||
rf = calc_risk_fraction(direction, upper, sl)
|
||||
if rf is None or rf <= 0:
|
||||
return None, "止损与补仓区间边界组合无法计算风险比例"
|
||||
|
||||
risk_budget = capital * (rp / 100.0)
|
||||
notional = risk_budget / rf
|
||||
margin_plan = min(notional / float(lev), capital * MARGIN_BUFFER)
|
||||
if margin_plan <= 0:
|
||||
return None, "计划保证金过小"
|
||||
|
||||
target_amt = _amount_from_margin(margin_plan, lev, entry, cs)
|
||||
if target_amt is None or target_amt <= 0:
|
||||
return None, "无法计算计划张数,请检查入场价与杠杆"
|
||||
target_amt = amount_precise(target_amt)
|
||||
if target_amt is None or target_amt <= 0:
|
||||
return None, "计划张数低于交易所最小精度"
|
||||
|
||||
def _amount_precise(_symbol: str, amount: float) -> Optional[float]:
|
||||
return amount_precise(amount)
|
||||
|
||||
payload, err = compute_trend_plan_core(
|
||||
direction=direction,
|
||||
stop_loss=sl,
|
||||
add_upper=upper,
|
||||
risk_percent=rp,
|
||||
snapshot_usdt=capital,
|
||||
leverage=lev,
|
||||
live_price=entry,
|
||||
target_order_amount=target_amt,
|
||||
exchange_symbol=exchange_symbol,
|
||||
dca_legs=legs,
|
||||
amount_precise=_amount_precise,
|
||||
min_amount=float(market.get("min_amount") or 0.0),
|
||||
full_margin_buffer_ratio=MARGIN_BUFFER,
|
||||
)
|
||||
if err:
|
||||
return None, err
|
||||
|
||||
payload["take_profit"] = tp
|
||||
payload["leverage"] = lev
|
||||
payload["contract_size"] = cs
|
||||
preview, rows = build_trend_preview_level_rows(payload)
|
||||
|
||||
px_dec = int(market.get("price_decimals") or 4)
|
||||
amt_dec = int(market.get("amount_decimals") or 4)
|
||||
|
||||
def _f(v: Any, nd: int | None = None) -> Any:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), nd if nd is not None else 8)
|
||||
except (TypeError, ValueError):
|
||||
return v
|
||||
|
||||
table = []
|
||||
for row in rows:
|
||||
table.append(
|
||||
{
|
||||
"label": row.get("label"),
|
||||
"price": _f(row.get("price"), px_dec),
|
||||
"contracts": _f(row.get("contracts"), amt_dec),
|
||||
"avg_entry": _f(row.get("avg_entry"), px_dec),
|
||||
"profit_u": _f(row.get("profit_u")),
|
||||
"risk_u": _f(row.get("risk_u")),
|
||||
"rr": _f(row.get("rr"), 4),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"direction": direction,
|
||||
"capital_usdt": _f(capital),
|
||||
"risk_percent": _f(rp, 2),
|
||||
"risk_budget_u": _f(preview.get("preview_risk_amount_u")),
|
||||
"leverage": lev,
|
||||
"entry_price": _f(entry, px_dec),
|
||||
"stop_loss": _f(sl, px_dec),
|
||||
"add_upper": _f(upper, px_dec),
|
||||
"take_profit": _f(tp, px_dec),
|
||||
"plan_margin_u": _f(preview.get("plan_margin_capital")),
|
||||
"target_contracts": _f(preview.get("target_order_amount"), amt_dec),
|
||||
"first_contracts": _f(preview.get("first_order_amount"), amt_dec),
|
||||
"dca_legs": int(preview.get("dca_legs") or legs),
|
||||
"first_profit_u": _f(preview.get("preview_first_profit_u")),
|
||||
"first_rr": _f(preview.get("preview_target_rr"), 4),
|
||||
"market": market,
|
||||
"rows": table,
|
||||
}, None
|
||||
|
||||
|
||||
def _amount_from_margin(
|
||||
margin_capital: float,
|
||||
leverage: int,
|
||||
price: float,
|
||||
contract_size: float,
|
||||
) -> Optional[float]:
|
||||
try:
|
||||
margin = float(margin_capital)
|
||||
lev = int(leverage)
|
||||
px = float(price)
|
||||
cs = float(contract_size) if contract_size else 1.0
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if margin <= 0 or lev <= 0 or px <= 0 or cs <= 0:
|
||||
return None
|
||||
notional = margin * lev
|
||||
return notional / (px * cs)
|
||||
|
||||
|
||||
def _round(v: Any, nd: int = 4) -> Any:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
return round(float(v), nd)
|
||||
except (TypeError, ValueError):
|
||||
return v
|
||||
|
||||
|
||||
def _money_rr(profit_u: Optional[float], risk_u: Optional[float]) -> Optional[float]:
|
||||
try:
|
||||
if risk_u is None or float(risk_u) <= 0 or profit_u is None:
|
||||
return None
|
||||
return round(float(profit_u) / float(risk_u), 4)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def calc_initial_roll_qty(
|
||||
direction: str,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
risk_budget_usdt: float,
|
||||
contract_size: float = 1.0,
|
||||
) -> Tuple[Optional[float], Optional[str]]:
|
||||
"""首仓以损定仓:打到初始止损亏损 = 风险预算."""
|
||||
try:
|
||||
entry = float(entry_price)
|
||||
sl = float(stop_loss)
|
||||
budget = float(risk_budget_usdt)
|
||||
cs = float(contract_size) if contract_size else 1.0
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if entry <= 0 or sl <= 0 or budget <= 0 or cs <= 0:
|
||||
return None, "入场价,止损与风险预算须大于 0"
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
per_unit = (sl - entry) * cs
|
||||
if per_unit <= 0:
|
||||
return None, "做空:止损价须高于首仓入场价"
|
||||
else:
|
||||
per_unit = (entry - sl) * cs
|
||||
if per_unit <= 0:
|
||||
return None, "做多:止损价须低于首仓入场价"
|
||||
return budget / per_unit, None
|
||||
|
||||
|
||||
def solve_add_amount_for_total_risk(
|
||||
direction: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
add_price: float,
|
||||
new_stop: float,
|
||||
risk_budget_usdt: float,
|
||||
contract_size: float = 1.0,
|
||||
) -> Tuple[Optional[float], Optional[str]]:
|
||||
"""合并持仓打到新止损总亏损 = 风险预算,反推本次加仓张数."""
|
||||
try:
|
||||
q1 = float(qty_existing)
|
||||
e1 = float(entry_existing)
|
||||
e2 = float(add_price)
|
||||
sl = float(new_stop)
|
||||
b = float(risk_budget_usdt)
|
||||
cs = float(contract_size) if contract_size else 1.0
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if q1 <= 0 or e1 <= 0 or e2 <= 0 or b <= 0 or cs <= 0:
|
||||
return None, "持仓或风险预算无效"
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction == "short":
|
||||
denom = sl - e2
|
||||
numer = b / cs - q1 * (sl - e1)
|
||||
if denom <= 0:
|
||||
return None, "做空:新止损须高于限价加仓价"
|
||||
else:
|
||||
denom = e2 - sl
|
||||
numer = b / cs - q1 * (e1 - sl)
|
||||
if denom <= 0:
|
||||
return None, "做多:新止损须低于限价/市价加仓价"
|
||||
q2 = numer / denom
|
||||
if q2 <= 0:
|
||||
return None, "按当前新止损与总风险%,无需加仓或无法再加(已满足风险上限)"
|
||||
return q2, None
|
||||
|
||||
|
||||
def _roll_leg_preview(
|
||||
*,
|
||||
direction: str,
|
||||
qty_existing: float,
|
||||
entry_existing: float,
|
||||
take_profit: float,
|
||||
add_price: float,
|
||||
new_stop_loss: float,
|
||||
risk_budget: float,
|
||||
contract_size: float,
|
||||
amount_precise: Callable[[float], Optional[float]],
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
direction = (direction or "long").strip().lower()
|
||||
try:
|
||||
tp = float(take_profit)
|
||||
sl = float(new_stop_loss)
|
||||
entry_add = float(add_price)
|
||||
e1 = float(entry_existing)
|
||||
except (TypeError, ValueError):
|
||||
return None, "止损/止盈格式错误"
|
||||
if sl <= 0 or tp <= 0 or entry_add <= 0:
|
||||
return None, "止损与首仓止盈须大于0"
|
||||
if direction == "long":
|
||||
if sl >= entry_add:
|
||||
return None, "做多:新止损须低于加仓价"
|
||||
if tp <= e1:
|
||||
return None, "做多:首仓止盈须高于当前持仓均价参考"
|
||||
else:
|
||||
if sl <= entry_add:
|
||||
return None, "做空:新止损须高于加仓价"
|
||||
if tp >= e1:
|
||||
return None, "做空:首仓止盈须低于当前持仓均价参考"
|
||||
|
||||
q2_raw, err = solve_add_amount_for_total_risk(
|
||||
direction,
|
||||
qty_existing,
|
||||
entry_existing,
|
||||
entry_add,
|
||||
sl,
|
||||
risk_budget,
|
||||
contract_size,
|
||||
)
|
||||
if err:
|
||||
return None, err
|
||||
q2 = amount_precise(float(q2_raw))
|
||||
if q2 is None or q2 <= 0:
|
||||
return None, "加仓张数低于交易所最小精度"
|
||||
new_qty = float(qty_existing) + float(q2)
|
||||
new_avg = (float(qty_existing) * float(entry_existing) + float(q2) * entry_add) / new_qty
|
||||
cs = float(contract_size) if contract_size else 1.0
|
||||
if direction == "long":
|
||||
loss_at_sl = (new_avg - sl) * new_qty * cs
|
||||
reward_gross = (tp - new_avg) * new_qty * cs
|
||||
else:
|
||||
loss_at_sl = (sl - new_avg) * new_qty * cs
|
||||
reward_gross = (new_avg - tp) * new_qty * cs
|
||||
try:
|
||||
from lib.trade.trade_fee_lib import net_pnl_after_fee
|
||||
|
||||
reward_at_tp = net_pnl_after_fee(reward_gross, new_avg, tp, new_qty, cs)
|
||||
if reward_at_tp is None:
|
||||
reward_at_tp = reward_gross
|
||||
except Exception:
|
||||
reward_at_tp = reward_gross
|
||||
return {
|
||||
"add_amount_raw": q2,
|
||||
"qty_after": new_qty,
|
||||
"avg_entry_after": new_avg,
|
||||
"add_price": entry_add,
|
||||
"new_stop_loss": sl,
|
||||
"loss_at_sl_usdt": loss_at_sl,
|
||||
"reward_at_tp_usdt": reward_at_tp,
|
||||
}, None
|
||||
|
||||
|
||||
def calc_roll_calculator(
|
||||
*,
|
||||
direction: str,
|
||||
capital_usdt: float,
|
||||
risk_percent: float,
|
||||
entry_price: float,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
add_legs: list[dict[str, float]] | None = None,
|
||||
legs_done: int = 0,
|
||||
exchange_id: str = "0",
|
||||
base: str = "ETH",
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
滚仓历史测算:首仓自动以损定仓;止盈锁定首仓价;最多 3 次滚仓加仓.
|
||||
add_legs: [{add_price, new_stop_loss}, ...],按顺序链式计算.
|
||||
legs_done: 已完成滚仓次数(仅标记,仍参与链式状态推进).
|
||||
"""
|
||||
market, amount_precise, merr = _resolve_market(exchange_id, base)
|
||||
if merr or not market or not amount_precise:
|
||||
return None, merr or "无法解析合约"
|
||||
contract_size = float(market.get("contract_size") or 1.0)
|
||||
px_dec = int(market.get("price_decimals") or 4)
|
||||
amt_dec = int(market.get("amount_decimals") or 4)
|
||||
|
||||
direction = (direction or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return None, "方向须为 long 或 short"
|
||||
try:
|
||||
capital = float(capital_usdt)
|
||||
rp = float(risk_percent)
|
||||
entry = float(entry_price)
|
||||
initial_sl = float(stop_loss)
|
||||
tp = float(take_profit)
|
||||
done = max(0, int(legs_done))
|
||||
except (TypeError, ValueError):
|
||||
return None, "参数格式错误"
|
||||
if capital <= 0 or rp <= 0 or entry <= 0 or initial_sl <= 0 or tp <= 0:
|
||||
return None, "资金,风险与价格须大于 0"
|
||||
if done > max_roll_legs(direction):
|
||||
return None, f"已完成滚仓次数不能超过 {max_roll_legs(direction)} 次"
|
||||
|
||||
legs_in: list[dict[str, float]] = []
|
||||
for raw in add_legs or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
try:
|
||||
ap = float(raw.get("add_price"))
|
||||
nsl = float(raw.get("new_stop_loss"))
|
||||
except (TypeError, ValueError):
|
||||
return None, "加仓价与新止损须为有效数字"
|
||||
if ap <= 0 or nsl <= 0:
|
||||
return None, "加仓价与新止损须大于 0"
|
||||
legs_in.append({"add_price": ap, "new_stop_loss": nsl})
|
||||
|
||||
if done + len(legs_in) > max_roll_legs(direction):
|
||||
return None, f"已完成 {done} 次 + 待测算 {len(legs_in)} 次,合计不能超过 {max_roll_legs(direction)} 次滚仓"
|
||||
|
||||
if direction == "long":
|
||||
if tp <= entry:
|
||||
return None, "做多:止盈价须高于首仓入场价"
|
||||
else:
|
||||
if tp >= entry:
|
||||
return None, "做空:止盈价须低于首仓入场价"
|
||||
|
||||
risk_budget = capital * (rp / 100.0)
|
||||
qty, err = calc_initial_roll_qty(direction, entry, initial_sl, risk_budget, contract_size)
|
||||
if err:
|
||||
return None, err
|
||||
if qty is None or qty <= 0:
|
||||
return None, "无法计算首仓张数"
|
||||
qty_p = amount_precise(float(qty))
|
||||
if qty_p is None or qty_p <= 0:
|
||||
return None, "首仓张数低于交易所最小精度"
|
||||
|
||||
qty_f = float(qty_p)
|
||||
avg = entry
|
||||
rows: list[dict[str, Any]] = []
|
||||
cs = contract_size
|
||||
|
||||
if direction == "long":
|
||||
first_loss = (avg - initial_sl) * qty_f * cs
|
||||
first_profit_gross = (tp - avg) * qty_f * cs
|
||||
else:
|
||||
first_loss = (initial_sl - avg) * qty_f * cs
|
||||
first_profit_gross = (avg - tp) * qty_f * cs
|
||||
try:
|
||||
from lib.trade.trade_fee_lib import net_pnl_after_fee
|
||||
|
||||
first_profit = net_pnl_after_fee(first_profit_gross, avg, tp, qty_f, cs)
|
||||
if first_profit is None:
|
||||
first_profit = first_profit_gross
|
||||
except Exception:
|
||||
first_profit = first_profit_gross
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"label": "首仓",
|
||||
"leg_index": 0,
|
||||
"already_done": False,
|
||||
"entry_or_add_price": _round(entry, px_dec),
|
||||
"stop_loss": _round(initial_sl, px_dec),
|
||||
"add_contracts": _round(qty_f, amt_dec),
|
||||
"total_contracts": _round(qty_f, amt_dec),
|
||||
"avg_entry": _round(avg, px_dec),
|
||||
"take_profit": _round(tp, px_dec),
|
||||
"loss_at_sl_u": _round(first_loss),
|
||||
"profit_at_tp_u": _round(first_profit),
|
||||
"rr": _money_rr(first_profit, first_loss),
|
||||
}
|
||||
)
|
||||
|
||||
current_qty = qty_f
|
||||
current_avg = avg
|
||||
|
||||
for i, leg in enumerate(legs_in):
|
||||
leg_no = i + 1
|
||||
preview, err = _roll_leg_preview(
|
||||
direction=direction,
|
||||
qty_existing=current_qty,
|
||||
entry_existing=current_avg,
|
||||
take_profit=tp,
|
||||
add_price=leg["add_price"],
|
||||
new_stop_loss=leg["new_stop_loss"],
|
||||
risk_budget=risk_budget,
|
||||
contract_size=cs,
|
||||
amount_precise=amount_precise,
|
||||
)
|
||||
if err:
|
||||
return None, f"滚仓第 {leg_no} 次:{err}"
|
||||
if not preview:
|
||||
return None, f"滚仓第 {leg_no} 次计算失败"
|
||||
|
||||
current_qty = float(preview["qty_after"])
|
||||
current_avg = float(preview["avg_entry_after"])
|
||||
loss = preview.get("loss_at_sl_usdt")
|
||||
reward = preview.get("reward_at_tp_usdt")
|
||||
rows.append(
|
||||
{
|
||||
"label": f"滚仓{leg_no}",
|
||||
"leg_index": leg_no,
|
||||
"already_done": leg_no <= done,
|
||||
"entry_or_add_price": _round(preview.get("add_price"), px_dec),
|
||||
"stop_loss": _round(preview.get("new_stop_loss"), px_dec),
|
||||
"add_contracts": _round(preview.get("add_amount_raw"), amt_dec),
|
||||
"total_contracts": _round(current_qty, amt_dec),
|
||||
"avg_entry": _round(current_avg, px_dec),
|
||||
"take_profit": _round(tp, px_dec),
|
||||
"loss_at_sl_u": _round(loss),
|
||||
"profit_at_tp_u": _round(reward),
|
||||
"rr": _money_rr(reward, loss),
|
||||
}
|
||||
)
|
||||
|
||||
last = rows[-1]
|
||||
return {
|
||||
"direction": direction,
|
||||
"capital_usdt": _round(capital),
|
||||
"risk_percent": _round(rp, 2),
|
||||
"risk_budget_u": _round(risk_budget),
|
||||
"entry_price": _round(entry, px_dec),
|
||||
"stop_loss": _round(initial_sl, px_dec),
|
||||
"take_profit": _round(tp, px_dec),
|
||||
"legs_done": done,
|
||||
"roll_legs_planned": len(legs_in),
|
||||
"first_contracts": _round(qty_f, amt_dec),
|
||||
"final_contracts": last.get("total_contracts"),
|
||||
"final_avg_entry": last.get("avg_entry"),
|
||||
"final_loss_at_sl_u": last.get("loss_at_sl_u"),
|
||||
"final_profit_at_tp_u": last.get("profit_at_tp_u"),
|
||||
"final_rr": last.get("rr"),
|
||||
"market": market,
|
||||
"rows": rows,
|
||||
}, None
|
||||
@@ -0,0 +1,257 @@
|
||||
"""计算器:从已配置交易实例读取 USDT 永续合约精度与张数规则."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
try:
|
||||
from settings_store import enabled_exchanges, load_settings
|
||||
except ImportError:
|
||||
from manual_trading_hub.settings_store import enabled_exchanges, load_settings
|
||||
|
||||
MARKET_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
MARKET_LOCK = threading.Lock()
|
||||
MARKET_TTL_SEC = 300.0
|
||||
HUB_FLASK_TIMEOUT = float(__import__("os").getenv("HUB_FLASK_TIMEOUT", "20"))
|
||||
|
||||
|
||||
def normalize_base_symbol(text: str) -> str:
|
||||
s = str(text or "").upper().strip()
|
||||
for suf in ("USDT:USDT", "/USDT:USDT", "/USDT", "USDT", "-USDT-SWAP"):
|
||||
if s.endswith(suf) and len(s) > len(suf):
|
||||
s = s[: -len(suf)].strip("-/")
|
||||
break
|
||||
if "/" in s:
|
||||
s = s.split("/", 1)[0].strip()
|
||||
if ":" in s:
|
||||
s = s.split(":", 1)[0].strip()
|
||||
return s
|
||||
|
||||
|
||||
def resolve_usdt_perp_symbol(exchange: Any, base: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
base_u = normalize_base_symbol(base)
|
||||
if not base_u:
|
||||
return None, "请输入币种,如 ETH"
|
||||
candidates = [f"{base_u}/USDT:USDT", f"{base_u}/USDT"]
|
||||
markets = getattr(exchange, "markets", None) or {}
|
||||
for sym in candidates:
|
||||
m = markets.get(sym)
|
||||
if not m:
|
||||
continue
|
||||
if m.get("active") is False:
|
||||
continue
|
||||
if m.get("swap") or m.get("linear") or m.get("contract"):
|
||||
return sym, None
|
||||
for sym, m in markets.items():
|
||||
if m.get("active") is False:
|
||||
continue
|
||||
if not (m.get("swap") or m.get("linear")):
|
||||
continue
|
||||
if (m.get("quote") or "").upper() != "USDT":
|
||||
continue
|
||||
if (m.get("base") or "").upper() == base_u:
|
||||
return sym, None
|
||||
return None, f"未找到 {base_u}/USDT 永续合约"
|
||||
|
||||
|
||||
def _decimals_from_precision_value(value: Any) -> Optional[int]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
p = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
|
||||
return int(round(p))
|
||||
if 0 < p < 1:
|
||||
s = f"{p:.12f}".rstrip("0")
|
||||
if "." in s:
|
||||
return min(12, len(s.split(".", 1)[1]))
|
||||
return None
|
||||
|
||||
|
||||
def _decimals_from_ccxt_str(text: str) -> int:
|
||||
s = str(text or "").strip()
|
||||
if not s or "." not in s:
|
||||
return 0
|
||||
frac = s.split(".", 1)[1]
|
||||
if not frac:
|
||||
return 0
|
||||
return min(12, len(frac.rstrip("0") or frac))
|
||||
|
||||
|
||||
def amount_decimals_from_exchange(exchange: Any, exchange_symbol: str) -> int:
|
||||
try:
|
||||
return _decimals_from_ccxt_str(exchange.amount_to_precision(exchange_symbol, 1.23456789))
|
||||
except Exception:
|
||||
market = exchange.market(exchange_symbol)
|
||||
prec = (market.get("precision") or {}).get("amount")
|
||||
d = _decimals_from_precision_value(prec)
|
||||
return d if d is not None else 4
|
||||
|
||||
|
||||
def price_decimals_from_exchange(
|
||||
exchange: Any, exchange_symbol: str, price_tick: Optional[float]
|
||||
) -> int:
|
||||
from lib.hub.hub_ohlcv_lib import normalize_price_tick
|
||||
|
||||
tick = normalize_price_tick(price_tick)
|
||||
if tick and tick > 0:
|
||||
if tick >= 1:
|
||||
return 0
|
||||
s = f"{tick:.12f}".rstrip("0")
|
||||
if "." in s:
|
||||
return min(12, len(s.split(".", 1)[1]))
|
||||
try:
|
||||
return _decimals_from_ccxt_str(exchange.price_to_precision(exchange_symbol, 12345.678901234))
|
||||
except Exception:
|
||||
market = exchange.market(exchange_symbol)
|
||||
prec = (market.get("precision") or {}).get("price")
|
||||
d = _decimals_from_precision_value(prec)
|
||||
return d if d is not None else 4
|
||||
|
||||
|
||||
def make_amount_precise_fn_from_market(market: dict[str, Any]) -> Callable[[float], Optional[float]]:
|
||||
dec = max(0, int(market.get("amount_decimals") or 4))
|
||||
min_amt = market.get("min_amount")
|
||||
|
||||
def _fn(amount: float) -> Optional[float]:
|
||||
try:
|
||||
v = float(amount)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if v <= 0:
|
||||
return None
|
||||
factor = 10**dec
|
||||
v = int(v * factor + 1e-12) / factor
|
||||
if min_amt is not None:
|
||||
try:
|
||||
if v < float(min_amt):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if v <= 0:
|
||||
return None
|
||||
return v
|
||||
|
||||
return _fn
|
||||
|
||||
|
||||
def find_exchange(exchange_id: str) -> dict | None:
|
||||
needle = str(exchange_id or "").strip()
|
||||
if not needle:
|
||||
return None
|
||||
for ex in load_settings().get("exchanges") or []:
|
||||
if str(ex.get("id") or "").strip() == needle:
|
||||
return ex
|
||||
if str(ex.get("key") or "").strip().lower() == needle.lower():
|
||||
return ex
|
||||
return None
|
||||
|
||||
|
||||
def list_calculator_exchanges() -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for ex in enabled_exchanges():
|
||||
rows.append(
|
||||
{
|
||||
"id": str(ex.get("id") or ""),
|
||||
"key": str(ex.get("key") or ""),
|
||||
"name": str(ex.get("name") or ex.get("key") or ""),
|
||||
"enabled": bool(ex.get("enabled")),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _hub_headers() -> dict[str, str]:
|
||||
import os
|
||||
|
||||
token = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN") or "").strip()
|
||||
if token:
|
||||
return {"X-Hub-Token": token}
|
||||
return {}
|
||||
|
||||
|
||||
def fetch_instance_market_sync(ex: dict, *, base: str) -> dict[str, Any]:
|
||||
base_url = (ex.get("flask_url") or "").rstrip("/")
|
||||
if not base_url:
|
||||
return {"ok": False, "msg": "未配置 flask_url"}
|
||||
params = urlencode({"base": normalize_base_symbol(base) or base})
|
||||
url = f"{base_url}/api/hub/market?{params}"
|
||||
req = urllib.request.Request(url, headers=_hub_headers(), method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HUB_FLASK_TIMEOUT) as resp:
|
||||
status = int(getattr(resp, "status", 200) or 200)
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
data = json.loads(raw) if raw else {}
|
||||
if not isinstance(data, dict):
|
||||
return {"ok": False, "msg": "无效 JSON"}
|
||||
if status >= 400:
|
||||
data.setdefault("ok", False)
|
||||
return data
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
raw = exc.read().decode("utf-8", errors="replace")
|
||||
body = json.loads(raw) if raw else {}
|
||||
except Exception:
|
||||
body = {"ok": False, "msg": raw if "raw" in locals() else str(exc)}
|
||||
if isinstance(body, dict):
|
||||
body.setdefault("ok", False)
|
||||
return body
|
||||
return {"ok": False, "msg": f"HTTP {exc.code}"}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "msg": str(exc)}
|
||||
|
||||
|
||||
def _enrich_market_from_settings(ex: dict, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(payload)
|
||||
out["exchange_id"] = str(ex.get("id") or "")
|
||||
out["exchange_key"] = str(ex.get("key") or "")
|
||||
out["exchange_name"] = str(ex.get("name") or ex.get("key") or "")
|
||||
out["exchange_label"] = out["exchange_name"]
|
||||
return out
|
||||
|
||||
|
||||
def get_calculator_market(
|
||||
exchange_id: str,
|
||||
base: str,
|
||||
*,
|
||||
ex: dict | None = None,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""从系统设置中的交易实例拉取合约精度(与实盘一致)."""
|
||||
row = ex or find_exchange(exchange_id)
|
||||
if not row:
|
||||
return None, "未找到该交易所配置"
|
||||
if not row.get("enabled"):
|
||||
return None, f"{row.get('name') or exchange_id} 未启用"
|
||||
|
||||
base_u = normalize_base_symbol(base)
|
||||
if not base_u:
|
||||
return None, "请输入币种,如 ETH"
|
||||
|
||||
cache_key = f"{row.get('id')}:{base_u}"
|
||||
now = time.time()
|
||||
with MARKET_LOCK:
|
||||
cached = MARKET_CACHE.get(cache_key)
|
||||
if cached and now - cached[0] < MARKET_TTL_SEC:
|
||||
return dict(cached[1]), None
|
||||
|
||||
remote = fetch_instance_market_sync(row, base=base_u)
|
||||
if not remote.get("ok"):
|
||||
return None, str(remote.get("msg") or "实例返回失败")
|
||||
|
||||
data = _enrich_market_from_settings(row, remote)
|
||||
with MARKET_LOCK:
|
||||
MARKET_CACHE[cache_key] = (now, data)
|
||||
return data, None
|
||||
|
||||
|
||||
def clear_market_cache() -> None:
|
||||
with MARKET_LOCK:
|
||||
MARKET_CACHE.clear()
|
||||
@@ -0,0 +1,400 @@
|
||||
"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def default_contract_size(base: str) -> float:
|
||||
"""OKX 线性永续常用面值(币/张);与计算器缺省一致."""
|
||||
b = (base or "ETH").strip().upper()
|
||||
return 0.01
|
||||
|
||||
|
||||
def default_ct_mult(base: str) -> float:
|
||||
return 0.01
|
||||
|
||||
|
||||
def floor_sheets(n: float, step: float = 1.0) -> float:
|
||||
if n is None or not math.isfinite(n) or n <= 0:
|
||||
return 0.0
|
||||
s = float(step) if step and step > 0 else 1.0
|
||||
return math.floor(n / s + 1e-12) * s
|
||||
|
||||
|
||||
def option_unit_cost(*, ask: float, ct_mult: float) -> float:
|
||||
return float(ask) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_intrinsic_value(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
) -> float:
|
||||
o = (opt_type or "").strip().upper()
|
||||
k = float(strike)
|
||||
s = float(spot)
|
||||
if o == "C":
|
||||
intrinsic = max(0.0, s - k)
|
||||
elif o == "P":
|
||||
intrinsic = max(0.0, k - s)
|
||||
else:
|
||||
intrinsic = 0.0
|
||||
return intrinsic * float(sheets) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_pnl_at_spot(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
) -> float:
|
||||
return option_intrinsic_value(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=spot,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
) - float(premium_paid)
|
||||
|
||||
|
||||
def perp_pnl(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
exit_px: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
) -> float:
|
||||
coins = float(contracts) * float(contract_size or 0.01)
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return (float(entry) - float(exit_px)) * coins
|
||||
return (float(exit_px) - float(entry)) * coins
|
||||
|
||||
|
||||
def _validate_common(inp: dict[str, Any]) -> Optional[str]:
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
if base not in ("ETH", "BTC"):
|
||||
return "标的仅支持 ETH / BTC"
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long / short"
|
||||
s0 = _f(inp.get("entry"))
|
||||
sl = _f(inp.get("sl"))
|
||||
tp = _f(inp.get("tp"))
|
||||
risk = _f(inp.get("risk_u"))
|
||||
if s0 is None or s0 <= 0:
|
||||
return "请填写有效入场价"
|
||||
if sl is None or sl <= 0:
|
||||
return "请填写有效止损价"
|
||||
if tp is None or tp <= 0:
|
||||
return "请填写有效止盈价"
|
||||
if risk is None or risk <= 0:
|
||||
return "请填写有效风险额 R"
|
||||
if direction == "long" and not (sl < s0 < tp):
|
||||
return "做多须满足 止损 < 入场 < 止盈"
|
||||
if direction == "short" and not (tp < s0 < sl):
|
||||
return "做空须满足 止盈 < 入场 < 止损"
|
||||
return None
|
||||
|
||||
|
||||
def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
s0 = float(inp["entry"])
|
||||
sl = float(inp["sl"])
|
||||
tp = float(inp["tp"])
|
||||
risk = float(inp["risk_u"])
|
||||
per_sheet_sl = abs(s0 - sl) * contract_size
|
||||
sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0
|
||||
actual_sl_loss = abs(perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size
|
||||
))
|
||||
tp_pnl = perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size
|
||||
)
|
||||
# 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利
|
||||
path_a = round(tp_pnl, 4)
|
||||
path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4)
|
||||
path_c_realized = path_b
|
||||
path_c_missed = path_a
|
||||
return {
|
||||
"kind": "perp",
|
||||
"sheets": sheets,
|
||||
"contract_size": contract_size,
|
||||
"per_sheet_sl_u": round(per_sheet_sl, 6),
|
||||
"risk_used_u": round(actual_sl_loss, 4),
|
||||
"path_a_tp": path_a,
|
||||
"path_b_sl": path_b,
|
||||
"path_c_realized": path_c_realized,
|
||||
"path_c_missed": path_c_missed,
|
||||
"path_c_note": "本单已止损;踏空未拿到原止盈空间",
|
||||
"worst_u": path_b,
|
||||
}
|
||||
|
||||
|
||||
def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
opt = inp.get("option") if isinstance(inp.get("option"), dict) else {}
|
||||
default_type = "C" if direction == "long" else "P"
|
||||
opt_type = str(opt.get("opt_type") or default_type).strip().upper()
|
||||
if opt_type not in ("C", "P"):
|
||||
opt_type = default_type
|
||||
strike = _f(opt.get("strike"))
|
||||
ask = _f(opt.get("ask"))
|
||||
if strike is None or strike <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权行权价"}
|
||||
if ask is None or ask <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权卖一价"}
|
||||
unit = option_unit_cost(ask=ask, ct_mult=ct_mult)
|
||||
sheets = floor_sheets(risk / unit) if unit > 0 else 0.0
|
||||
premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0
|
||||
# 若张数为 0
|
||||
path_a = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_at_sl = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_worst = -premium
|
||||
# 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "option",
|
||||
"opt_type": opt_type,
|
||||
"strike": strike,
|
||||
"ask": ask,
|
||||
"ct_mult": ct_mult,
|
||||
"sheets": sheets,
|
||||
"unit_cost_u": round(unit, 6),
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {}
|
||||
main_default = "C" if direction == "long" else "P"
|
||||
side_default = "P" if direction == "long" else "C"
|
||||
main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {}
|
||||
side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {}
|
||||
main_type = str(main.get("opt_type") or main_default).strip().upper()
|
||||
side_type = str(side.get("opt_type") or side_default).strip().upper()
|
||||
if main_type not in ("C", "P"):
|
||||
main_type = main_default
|
||||
if side_type not in ("C", "P"):
|
||||
side_type = side_default
|
||||
main_k = _f(main.get("strike"))
|
||||
main_ask = _f(main.get("ask"))
|
||||
side_k = _f(side.get("strike"))
|
||||
side_ask = _f(side.get("ask"))
|
||||
if None in (main_k, main_ask, side_k, side_ask) or min(
|
||||
main_k or 0, main_ask or 0, side_k or 0, side_ask or 0
|
||||
) <= 0:
|
||||
return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"}
|
||||
main_budget = 0.7 * risk
|
||||
side_budget = 0.3 * risk
|
||||
main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult)
|
||||
side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult)
|
||||
main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0
|
||||
side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0
|
||||
main_prem = main_unit * main_sheets
|
||||
side_prem = side_unit * side_sheets
|
||||
premium = main_prem + side_prem
|
||||
|
||||
def combo_at(spot: float) -> float:
|
||||
a = option_pnl_at_spot(
|
||||
opt_type=main_type,
|
||||
strike=float(main_k),
|
||||
spot=spot,
|
||||
sheets=main_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=main_prem,
|
||||
)
|
||||
b = option_pnl_at_spot(
|
||||
opt_type=side_type,
|
||||
strike=float(side_k),
|
||||
spot=spot,
|
||||
sheets=side_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=side_prem,
|
||||
)
|
||||
return a + b
|
||||
|
||||
path_a = combo_at(tp)
|
||||
path_b_at_sl = combo_at(sl)
|
||||
path_b_worst = -premium
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "hedge",
|
||||
"ratio": "7:3",
|
||||
"ct_mult": ct_mult,
|
||||
"main": {
|
||||
"opt_type": main_type,
|
||||
"strike": main_k,
|
||||
"ask": main_ask,
|
||||
"sheets": main_sheets,
|
||||
"premium_u": round(main_prem, 4),
|
||||
"budget_u": round(main_budget, 4),
|
||||
},
|
||||
"side": {
|
||||
"opt_type": side_type,
|
||||
"strike": side_k,
|
||||
"ask": side_ask,
|
||||
"sheets": side_sheets,
|
||||
"premium_u": round(side_prem, 4),
|
||||
"budget_u": round(side_budget, 4),
|
||||
},
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]:
|
||||
"""可解释规则推荐."""
|
||||
candidates: list[tuple[str, float, dict[str, Any]]] = []
|
||||
if perp and perp.get("sheets", 0) > 0:
|
||||
candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp))
|
||||
if opt and opt.get("ok") and opt.get("sheets", 0) > 0:
|
||||
candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt))
|
||||
if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0:
|
||||
candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge))
|
||||
if not candidates:
|
||||
return {
|
||||
"choice": "—",
|
||||
"reason": "输入不足,无法推荐",
|
||||
"bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"],
|
||||
}
|
||||
|
||||
best_name, best_a, _ = max(candidates, key=lambda x: x[1])
|
||||
perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0
|
||||
opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0
|
||||
hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0
|
||||
|
||||
# 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A
|
||||
perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0
|
||||
opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None
|
||||
hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None
|
||||
anti_whipsaw = False
|
||||
if perp_miss > 0 and (
|
||||
(opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0)
|
||||
):
|
||||
anti_whipsaw = True
|
||||
|
||||
# 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约
|
||||
others_max = max(opt_a, hedge_a, 0.0)
|
||||
choice = best_name
|
||||
if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99:
|
||||
choice = "合约"
|
||||
if anti_whipsaw:
|
||||
reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约"
|
||||
else:
|
||||
reason = "同风险下合约干净止盈赔付最高"
|
||||
elif anti_whipsaw and (opt_a > 0 or hedge_a > 0):
|
||||
# 抗踏空优先期权类;期期与单腿接近时推期期
|
||||
if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85):
|
||||
choice = "期期对冲"
|
||||
reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护"
|
||||
else:
|
||||
choice = "单期权"
|
||||
reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约"
|
||||
else:
|
||||
reason = f"同风险下「{best_name}」干净止盈赔付最高"
|
||||
|
||||
bullets = [
|
||||
f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)",
|
||||
(
|
||||
"止损与踏空:合约打止损即结束并可能踏空;"
|
||||
"期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标"
|
||||
if anti_whipsaw
|
||||
else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈"
|
||||
),
|
||||
f"选用建议:{reason}",
|
||||
]
|
||||
return {"choice": choice, "reason": reason, "bullets": bullets}
|
||||
|
||||
|
||||
def run_compare(inp: dict[str, Any]) -> dict[str, Any]:
|
||||
err = _validate_common(inp)
|
||||
if err:
|
||||
return {"ok": False, "msg": err}
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
risk = float(inp["risk_u"])
|
||||
cs = _f(inp.get("contract_size")) or default_contract_size(base)
|
||||
ct = _f(inp.get("ct_mult")) or default_ct_mult(base)
|
||||
perp = _calc_perp(inp, contract_size=float(cs))
|
||||
opt = _calc_single_option(inp, ct_mult=float(ct))
|
||||
hedge = _calc_hedge(inp, ct_mult=float(ct))
|
||||
rec = recommend(
|
||||
perp,
|
||||
opt if opt.get("ok") else {"ok": False},
|
||||
hedge if hedge.get("ok") else {"ok": False},
|
||||
risk,
|
||||
)
|
||||
warnings: list[str] = []
|
||||
if perp.get("sheets", 0) <= 0:
|
||||
warnings.append("合约张数为 0:止损距过大或 R 过小")
|
||||
if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0:
|
||||
warnings.append("单期权张数为 0:卖一过高或 R 过小")
|
||||
if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0:
|
||||
warnings.append("期期对冲未开出张数:卖一过高或 R 过小")
|
||||
if isinstance(opt, dict) and not opt.get("ok"):
|
||||
warnings.append(str(opt.get("msg") or "单期权输入不完整"))
|
||||
if isinstance(hedge, dict) and not hedge.get("ok"):
|
||||
warnings.append(str(hedge.get("msg") or "期期对冲输入不完整"))
|
||||
return {
|
||||
"ok": True,
|
||||
"base": base,
|
||||
"direction": str(inp.get("direction") or "long").strip().lower(),
|
||||
"entry": float(inp["entry"]),
|
||||
"sl": float(inp["sl"]),
|
||||
"tp": float(inp["tp"]),
|
||||
"risk_u": risk,
|
||||
"contract_size": float(cs),
|
||||
"ct_mult": float(ct),
|
||||
"perp": perp,
|
||||
"option": opt,
|
||||
"hedge": hedge,
|
||||
"recommend": rec,
|
||||
"warnings": warnings,
|
||||
"notes": [
|
||||
"期权止盈按标的到价的内在价值近似,非盘口卖出价",
|
||||
"到期小盈/小亏未纳入主表与推荐",
|
||||
"仅本地测算,不下单",
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from lib.hub.hub_volume_rank_lib import TOP_N_DEFAULT, get_cached_rank, volume_rank_timezone
|
||||
|
||||
SCAN_CACHE_VERSION = 1
|
||||
SCAN_TIMEFRAMES: tuple[str, ...] = ("4h", "1d", "1w")
|
||||
SWING_LOOKBACK = 4
|
||||
SWING_ALIGN_BARS = 30
|
||||
RECENCY_BARS = 60
|
||||
MACD_FAST = 12
|
||||
MACD_SLOW = 26
|
||||
MACD_SIGNAL = 9
|
||||
|
||||
TAB_LABELS: dict[str, str] = {
|
||||
"4h": "4h背离",
|
||||
"1d": "日线背离",
|
||||
"1w": "周线背离",
|
||||
}
|
||||
|
||||
TF_SHORT: dict[str, str] = {"4h": "4h", "1d": "日线", "1w": "周线"}
|
||||
|
||||
|
||||
def default_cache_path() -> Path:
|
||||
from lib.paths import hub_data_dir
|
||||
|
||||
return hub_data_dir() / "hub_divergence_scan.json"
|
||||
|
||||
|
||||
def ema_array(values: Sequence[float | None], period: int) -> list[float | None]:
|
||||
out: list[float | None] = [None] * len(values)
|
||||
if period <= 0 or len(values) < period:
|
||||
return out
|
||||
k = 2.0 / (period + 1)
|
||||
sma = sum(v for v in values[:period] if v is not None) / period
|
||||
out[period - 1] = sma
|
||||
prev = sma
|
||||
for i in range(period, len(values)):
|
||||
v = values[i]
|
||||
if v is None:
|
||||
continue
|
||||
prev = v * k + prev * (1 - k)
|
||||
out[i] = prev
|
||||
return out
|
||||
|
||||
|
||||
def find_swings(values: Sequence[float | None], lookback: int) -> tuple[list[dict], list[dict]]:
|
||||
lows: list[dict] = []
|
||||
highs: list[dict] = []
|
||||
lb = max(1, int(lookback))
|
||||
n = len(values)
|
||||
for i in range(lb, n - lb):
|
||||
v = values[i]
|
||||
if v is None:
|
||||
continue
|
||||
is_low = True
|
||||
is_high = True
|
||||
for j in range(1, lb + 1):
|
||||
lv = values[i - j]
|
||||
rv = values[i + j]
|
||||
if lv is None or rv is None or v > lv or v > rv:
|
||||
is_low = False
|
||||
if lv is None or rv is None or v < lv or v < rv:
|
||||
is_high = False
|
||||
if is_low:
|
||||
lows.append({"i": i, "v": float(v)})
|
||||
if is_high:
|
||||
highs.append({"i": i, "v": float(v)})
|
||||
return lows, highs
|
||||
|
||||
|
||||
def build_macd_by_index(closes: Sequence[float]) -> list[float | None]:
|
||||
ema12 = ema_array(closes, MACD_FAST)
|
||||
ema26 = ema_array(closes, MACD_SLOW)
|
||||
macd: list[float | None] = [None] * len(closes)
|
||||
for i in range(len(closes)):
|
||||
if ema12[i] is not None and ema26[i] is not None:
|
||||
macd[i] = ema12[i] - ema26[i]
|
||||
return macd
|
||||
|
||||
|
||||
def detect_latest_macd_divergence(
|
||||
closes: Sequence[float],
|
||||
*,
|
||||
swing_lookback: int = SWING_LOOKBACK,
|
||||
align_bars: int = SWING_ALIGN_BARS,
|
||||
recency_bars: int = RECENCY_BARS,
|
||||
) -> dict[str, Any]:
|
||||
"""档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类)."""
|
||||
if len(closes) < swing_lookback * 2 + 10:
|
||||
return {"direction": None}
|
||||
macd = build_macd_by_index(closes)
|
||||
p_lows, p_highs = find_swings(closes, swing_lookback)
|
||||
i_lows, i_highs = find_swings(macd, swing_lookback)
|
||||
|
||||
def recent_enough(idx: int) -> bool:
|
||||
return idx >= max(0, len(closes) - recency_bars)
|
||||
|
||||
if len(p_lows) >= 2 and len(i_lows) >= 2:
|
||||
p1, p2 = p_lows[-2], p_lows[-1]
|
||||
i1, i2 = i_lows[-2], i_lows[-1]
|
||||
if (
|
||||
abs(p1["i"] - i1["i"]) < align_bars
|
||||
and abs(p2["i"] - i2["i"]) < align_bars
|
||||
and p2["v"] < p1["v"]
|
||||
and i2["v"] > i1["v"]
|
||||
and recent_enough(p2["i"])
|
||||
):
|
||||
return {"direction": "bull", "bar_index": p2["i"]}
|
||||
|
||||
if len(p_highs) >= 2 and len(i_highs) >= 2:
|
||||
p1, p2 = p_highs[-2], p_highs[-1]
|
||||
i1, i2 = i_highs[-2], i_highs[-1]
|
||||
if (
|
||||
abs(p1["i"] - i1["i"]) < align_bars
|
||||
and abs(p2["i"] - i2["i"]) < align_bars
|
||||
and p2["v"] > p1["v"]
|
||||
and i2["v"] < i1["v"]
|
||||
and recent_enough(p2["i"])
|
||||
):
|
||||
return {"direction": "bear", "bar_index": p2["i"]}
|
||||
|
||||
return {"direction": None}
|
||||
|
||||
|
||||
def chart_candles_to_bars(candles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for c in candles:
|
||||
try:
|
||||
t = c.get("time")
|
||||
if t is None:
|
||||
continue
|
||||
ms = int(t) * 1000 if int(t) < 10_000_000_000 else int(t)
|
||||
out.append(
|
||||
{
|
||||
"open_time_ms": ms,
|
||||
"open": float(c["open"]),
|
||||
"high": float(c["high"]),
|
||||
"low": float(c["low"]),
|
||||
"close": float(c["close"]),
|
||||
"volume": float(c.get("volume") or 0),
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def normalize_ohlcv_rows(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
if not rows:
|
||||
return []
|
||||
first = rows[0]
|
||||
if first.get("open_time_ms") is not None:
|
||||
return [dict(r) for r in rows]
|
||||
return chart_candles_to_bars(rows)
|
||||
|
||||
|
||||
def bars_to_closes(bars: Sequence[Mapping[str, Any]], *, exclude_open: bool = True) -> list[float]:
|
||||
rows = list(bars)
|
||||
if exclude_open and len(rows) > 1:
|
||||
rows = rows[:-1]
|
||||
out: list[float] = []
|
||||
for b in rows:
|
||||
try:
|
||||
out.append(float(b["close"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def bar_time_at(bars: Sequence[Mapping[str, Any]], index: int) -> int | None:
|
||||
if index < 0 or index >= len(bars):
|
||||
return None
|
||||
try:
|
||||
return int(bars[index]["open_time_ms"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def analyze_ohlcv_bars(bars: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
closed = list(bars)
|
||||
if len(closed) > 1:
|
||||
closed = closed[:-1]
|
||||
closes = bars_to_closes(bars, exclude_open=True)
|
||||
hit = detect_latest_macd_divergence(closes)
|
||||
direction = hit.get("direction")
|
||||
bar_index = hit.get("bar_index")
|
||||
open_time_ms = None
|
||||
if direction and bar_index is not None:
|
||||
open_time_ms = bar_time_at(closed, int(bar_index))
|
||||
bars_ago = None
|
||||
if direction and bar_index is not None:
|
||||
bars_ago = max(0, len(closed) - 1 - int(bar_index))
|
||||
return {
|
||||
"direction": direction,
|
||||
"bar_index": bar_index,
|
||||
"open_time_ms": open_time_ms,
|
||||
"bars_ago": bars_ago,
|
||||
}
|
||||
|
||||
|
||||
def compute_confluence(tf_hits: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
|
||||
dirs: dict[str, str] = {}
|
||||
for tf in SCAN_TIMEFRAMES:
|
||||
d = (tf_hits.get(tf) or {}).get("direction")
|
||||
if d in ("bull", "bear"):
|
||||
dirs[tf] = d
|
||||
|
||||
if not dirs:
|
||||
return {
|
||||
"confluence": 0,
|
||||
"confluence_kind": "none",
|
||||
"confluence_css": "none",
|
||||
"is_split": False,
|
||||
"split_detail": "",
|
||||
"primary_direction": None,
|
||||
"direction_label": "",
|
||||
"timeframes_hit": [],
|
||||
}
|
||||
|
||||
unique = set(dirs.values())
|
||||
if len(unique) > 1:
|
||||
parts = []
|
||||
for tf in SCAN_TIMEFRAMES:
|
||||
if tf in dirs:
|
||||
label = "底" if dirs[tf] == "bull" else "顶"
|
||||
parts.append(f"{TF_SHORT.get(tf, tf)}{label}")
|
||||
return {
|
||||
"confluence": 0,
|
||||
"confluence_kind": "分歧",
|
||||
"confluence_css": "split",
|
||||
"is_split": True,
|
||||
"split_detail": " · ".join(parts),
|
||||
"primary_direction": _latest_direction(tf_hits),
|
||||
"direction_label": "分歧",
|
||||
"timeframes_hit": list(dirs.keys()),
|
||||
}
|
||||
|
||||
direction = next(iter(unique))
|
||||
count = len(dirs)
|
||||
return {
|
||||
"confluence": count,
|
||||
"confluence_kind": f"{count}周期",
|
||||
"confluence_css": f"c{count}",
|
||||
"is_split": False,
|
||||
"split_detail": "",
|
||||
"primary_direction": direction,
|
||||
"direction_label": "底背离" if direction == "bull" else "顶背离",
|
||||
"timeframes_hit": list(dirs.keys()),
|
||||
}
|
||||
|
||||
|
||||
def _latest_direction(tf_hits: Mapping[str, Mapping[str, Any]]) -> str | None:
|
||||
best_tf = None
|
||||
best_ms = -1
|
||||
for tf in SCAN_TIMEFRAMES:
|
||||
row = tf_hits.get(tf) or {}
|
||||
d = row.get("direction")
|
||||
ms = row.get("open_time_ms")
|
||||
if d not in ("bull", "bear") or ms is None:
|
||||
continue
|
||||
if int(ms) > best_ms:
|
||||
best_ms = int(ms)
|
||||
best_tf = tf
|
||||
if best_tf is None:
|
||||
return None
|
||||
return (tf_hits.get(best_tf) or {}).get("direction")
|
||||
|
||||
|
||||
def freshness_label(timeframe: str, bars_ago: int | None) -> str:
|
||||
if bars_ago is None:
|
||||
return ""
|
||||
n = int(bars_ago)
|
||||
if timeframe == "1w":
|
||||
return f"{n}周前" if n else "本周"
|
||||
if n <= 0:
|
||||
return "当根"
|
||||
return f"{n}根K前"
|
||||
|
||||
|
||||
def build_symbol_scan_row(
|
||||
*,
|
||||
rank: int,
|
||||
symbol: str,
|
||||
volume_label: str,
|
||||
tf_hits: Mapping[str, Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
conf = compute_confluence(tf_hits)
|
||||
tf_map = {tf: (tf_hits.get(tf) or {}).get("direction") for tf in SCAN_TIMEFRAMES}
|
||||
return {
|
||||
"rank": rank,
|
||||
"symbol": symbol,
|
||||
"volume_label": volume_label,
|
||||
"direction": conf.get("primary_direction"),
|
||||
"direction_label": conf.get("direction_label") or "",
|
||||
"confluence": conf.get("confluence") or 0,
|
||||
"confluence_kind": conf.get("confluence_kind") or "none",
|
||||
"confluence_css": conf.get("confluence_css") or "none",
|
||||
"is_split": bool(conf.get("is_split")),
|
||||
"split_detail": conf.get("split_detail") or "",
|
||||
"timeframes": tf_map,
|
||||
"tf_detail": {
|
||||
tf: {
|
||||
"direction": (tf_hits.get(tf) or {}).get("direction"),
|
||||
"open_time_ms": (tf_hits.get(tf) or {}).get("open_time_ms"),
|
||||
"bars_ago": (tf_hits.get(tf) or {}).get("bars_ago"),
|
||||
"freshness": freshness_label(tf, (tf_hits.get(tf) or {}).get("bars_ago")),
|
||||
}
|
||||
for tf in SCAN_TIMEFRAMES
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def filter_tab_items(items: Sequence[Mapping[str, Any]], tab: str) -> list[dict[str, Any]]:
|
||||
tab = (tab or "").strip().lower()
|
||||
if tab not in SCAN_TIMEFRAMES:
|
||||
return [dict(x) for x in items]
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in items:
|
||||
tf = (row.get("tf_detail") or {}).get(tab) or {}
|
||||
if tf.get("direction") not in ("bull", "bear"):
|
||||
continue
|
||||
item = dict(row)
|
||||
item["tab_timeframe"] = tab
|
||||
item["tab_direction"] = tf.get("direction")
|
||||
item["tab_direction_label"] = "底背离" if tf.get("direction") == "bull" else "顶背离"
|
||||
item["tab_freshness"] = tf.get("freshness") or ""
|
||||
item["tab_open_time_ms"] = tf.get("open_time_ms")
|
||||
out.append(item)
|
||||
out.sort(
|
||||
key=lambda x: (
|
||||
-1 if x.get("is_split") else int(x.get("confluence") or 0),
|
||||
int(x.get("rank") or 999),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def load_scan_cache(path: Path | None = None) -> dict[str, Any]:
|
||||
p = path or default_cache_path()
|
||||
if not p.is_file():
|
||||
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||
if int(data.get("version") or 0) < SCAN_CACHE_VERSION:
|
||||
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||
data.setdefault("version", SCAN_CACHE_VERSION)
|
||||
data.setdefault("exchanges", {})
|
||||
return data
|
||||
except Exception:
|
||||
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||
|
||||
|
||||
def save_scan_cache(data: dict[str, Any], path: Path | None = None) -> None:
|
||||
p = path or default_cache_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = dict(data)
|
||||
payload["version"] = SCAN_CACHE_VERSION
|
||||
payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds")
|
||||
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def merge_exchange_scan(
|
||||
cache: dict[str, Any],
|
||||
exchange_key: str,
|
||||
*,
|
||||
rank_date: str | None,
|
||||
items: list[dict[str, Any]],
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
ex_k = str(exchange_key or "").strip().lower()
|
||||
exchanges = dict(cache.get("exchanges") or {})
|
||||
exchanges[ex_k] = {
|
||||
"rank_date": rank_date,
|
||||
"items": items,
|
||||
"error": error,
|
||||
"scanned_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"),
|
||||
}
|
||||
out = dict(cache)
|
||||
out["exchanges"] = exchanges
|
||||
return out
|
||||
|
||||
|
||||
def get_cached_scan(
|
||||
cache: dict[str, Any],
|
||||
exchange_key: str,
|
||||
*,
|
||||
tab: str = "4h",
|
||||
) -> dict[str, Any]:
|
||||
ex_k = str(exchange_key or "").strip().lower()
|
||||
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
||||
all_items = list(ex_data.get("items") or [])
|
||||
tab_key = (tab or "4h").strip().lower()
|
||||
items = filter_tab_items(all_items, tab_key) if tab_key in SCAN_TIMEFRAMES else all_items
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange_key": ex_k,
|
||||
"tab": tab_key,
|
||||
"rank_date": ex_data.get("rank_date"),
|
||||
"updated_at": cache.get("updated_at"),
|
||||
"scanned_at": ex_data.get("scanned_at"),
|
||||
"items": items,
|
||||
"item_count": len(items),
|
||||
"error": ex_data.get("error"),
|
||||
}
|
||||
|
||||
|
||||
def scan_top_symbols(
|
||||
rank_items: Sequence[Mapping[str, Any]],
|
||||
fetch_bars: Callable[[str, str], Sequence[Mapping[str, Any]]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""对 Top N 币种扫描三周期背离.fetch_bars(symbol, timeframe) -> OHLCV rows."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rank_items:
|
||||
symbol = str(row.get("symbol") or "").strip().upper()
|
||||
if not symbol:
|
||||
continue
|
||||
tf_hits: dict[str, dict[str, Any]] = {}
|
||||
for tf in SCAN_TIMEFRAMES:
|
||||
try:
|
||||
bars = fetch_bars(symbol, tf)
|
||||
tf_hits[tf] = analyze_ohlcv_bars(bars)
|
||||
except Exception:
|
||||
tf_hits[tf] = {"direction": None}
|
||||
out.append(
|
||||
build_symbol_scan_row(
|
||||
rank=int(row.get("rank") or 0),
|
||||
symbol=symbol,
|
||||
volume_label=str(row.get("volume_label") or row.get("volume_quote") or ""),
|
||||
tf_hits=tf_hits,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def cache_is_stale(
|
||||
cache: dict[str, Any],
|
||||
exchange_key: str,
|
||||
*,
|
||||
rank_date: str | None,
|
||||
max_age_sec: float = 3600.0,
|
||||
) -> bool:
|
||||
ex_k = str(exchange_key or "").strip().lower()
|
||||
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
||||
if not ex_data.get("items") and not ex_data.get("error"):
|
||||
return True
|
||||
if rank_date and ex_data.get("rank_date") != rank_date:
|
||||
return True
|
||||
updated = cache.get("updated_at") or ex_data.get("scanned_at")
|
||||
if not updated:
|
||||
return True
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(updated))
|
||||
age = (datetime.now(dt.tzinfo) - dt).total_seconds()
|
||||
return age > max_age_sec
|
||||
except Exception:
|
||||
return True
|
||||
@@ -0,0 +1,453 @@
|
||||
"""中控开仓计划:进行中 / 历史归档 / 胜率统计."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
PLAN_TYPES = {
|
||||
"trend": "趋势单",
|
||||
"swing": "波段单",
|
||||
"intraday": "日内短线",
|
||||
}
|
||||
TREND_TIMEFRAMES = ("5m", "15m", "30m", "1h", "4h", "1d")
|
||||
ENTRY_TIMEFRAMES = ("1m", "5m", "15m", "30m", "1h")
|
||||
DIRECTIONS = {"long": "多", "short": "空"}
|
||||
ENTRY_SCHEMES = {
|
||||
"breakout": "突破方案",
|
||||
"false_breakout": "假突破突破方案",
|
||||
"box_inflection": "箱体拐点方案",
|
||||
}
|
||||
RESULTS = {"win": "盈", "loss": "亏"}
|
||||
STAT_DIMENSIONS = ("symbol", "trend_tf", "entry_scheme")
|
||||
|
||||
DISPLAY_TZ = ZoneInfo(
|
||||
(os.getenv("HUB_ENTRY_PLAN_TZ") or os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip()
|
||||
or "Asia/Shanghai"
|
||||
)
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
raw = (os.getenv("HUB_ENTRY_PLAN_DB_PATH") or "").strip()
|
||||
if raw:
|
||||
return Path(raw)
|
||||
from lib.paths import hub_data_dir
|
||||
|
||||
return hub_data_dir() / "hub_entry_plans.db"
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
path = db_path or default_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: Path | None = None) -> None:
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS entry_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_date TEXT NOT NULL,
|
||||
exchange_key TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
plan_type TEXT NOT NULL,
|
||||
trend_timeframe TEXT NOT NULL,
|
||||
entry_timeframe TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
target_level TEXT NOT NULL DEFAULT '',
|
||||
current_range TEXT NOT NULL DEFAULT '',
|
||||
entry_scheme TEXT NOT NULL,
|
||||
result TEXT,
|
||||
pnl_amount REAL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
archived_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_plans_status_date
|
||||
ON entry_plans (status, plan_date DESC, id DESC)
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def normalize_plan_symbol(raw: str) -> str:
|
||||
s = str(raw or "").strip().upper()
|
||||
if not s:
|
||||
raise ValueError("缺少币种")
|
||||
if ":" in s:
|
||||
s = s.split(":", 1)[0]
|
||||
if "/" in s:
|
||||
base, quote = s.split("/", 1)
|
||||
base = base.strip()
|
||||
quote = (quote or "USDT").strip() or "USDT"
|
||||
if not base:
|
||||
raise ValueError("币种无效")
|
||||
return f"{base}/{quote}"
|
||||
if s.endswith("USDT") and len(s) > 4:
|
||||
return f"{s[:-4]}/{s[-4:]}"
|
||||
return f"{s}/USDT"
|
||||
|
||||
|
||||
def _validate_choice(value: str, allowed: dict[str, str] | tuple[str, ...], field: str) -> str:
|
||||
key = str(value or "").strip().lower()
|
||||
if isinstance(allowed, dict):
|
||||
if key not in allowed:
|
||||
raise ValueError(f"{field} 无效")
|
||||
return key
|
||||
if key not in allowed:
|
||||
raise ValueError(f"{field} 无效")
|
||||
return key
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
d["plan_type_label"] = PLAN_TYPES.get(d.get("plan_type") or "", d.get("plan_type") or "")
|
||||
d["direction_label"] = DIRECTIONS.get(d.get("direction") or "", d.get("direction") or "")
|
||||
d["entry_scheme_label"] = ENTRY_SCHEMES.get(
|
||||
d.get("entry_scheme") or "", d.get("entry_scheme") or ""
|
||||
) or "待填写"
|
||||
res = d.get("result")
|
||||
d["result_label"] = RESULTS.get(res, "") if res else ""
|
||||
return d
|
||||
|
||||
|
||||
def _parse_optional_pnl(raw: Any) -> float | None:
|
||||
if raw is None or raw == "":
|
||||
return None
|
||||
try:
|
||||
return round(float(raw), 4)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError("盈亏金额无效") from e
|
||||
|
||||
|
||||
def create_entry_plan(payload: dict[str, Any], *, db_path: Path | None = None) -> dict[str, Any]:
|
||||
init_db(db_path)
|
||||
plan_date = str(payload.get("plan_date") or "").strip()[:10]
|
||||
if not plan_date:
|
||||
raise ValueError("缺少 plan_date")
|
||||
exchange_key = str(payload.get("exchange_key") or "").strip().lower()
|
||||
if not exchange_key:
|
||||
raise ValueError("缺少 exchange_key")
|
||||
symbol = normalize_plan_symbol(payload.get("symbol") or "")
|
||||
plan_type = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
|
||||
trend_tf = _validate_choice(payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期")
|
||||
entry_tf = _validate_choice(payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期")
|
||||
direction = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
|
||||
entry_scheme = ""
|
||||
if payload.get("entry_scheme"):
|
||||
entry_scheme = _validate_choice(payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案")
|
||||
target_level = str(payload.get("target_level") or "").strip()
|
||||
current_range = str(payload.get("current_range") or "").strip()
|
||||
note = str(payload.get("note") or "").strip()
|
||||
now = _now_ms()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO entry_plans (
|
||||
plan_date, exchange_key, symbol, plan_type, trend_timeframe, entry_timeframe,
|
||||
direction, target_level, current_range, entry_scheme, note, status,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)
|
||||
""",
|
||||
(
|
||||
plan_date,
|
||||
exchange_key,
|
||||
symbol,
|
||||
plan_type,
|
||||
trend_tf,
|
||||
entry_tf,
|
||||
direction,
|
||||
target_level,
|
||||
current_range,
|
||||
entry_scheme,
|
||||
note,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM entry_plans WHERE id=?",
|
||||
(int(cur.lastrowid),),
|
||||
).fetchone()
|
||||
return _row_to_dict(row) or {}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_entry_plans(
|
||||
*,
|
||||
status: str = "active",
|
||||
db_path: Path | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
init_db(db_path)
|
||||
st = (status or "active").strip().lower()
|
||||
if st not in ("active", "archived"):
|
||||
raise ValueError("status 无效")
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM entry_plans
|
||||
WHERE status=?
|
||||
ORDER BY plan_date DESC, id DESC
|
||||
""",
|
||||
(st,),
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows if r]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_entry_plan(plan_id: int, *, db_path: Path | None = None) -> dict[str, Any] | None:
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||
return _row_to_dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_entry_plan(
|
||||
plan_id: int,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if row["status"] == "archived":
|
||||
raise ValueError("已归档计划不可修改")
|
||||
fields: dict[str, Any] = {}
|
||||
if "plan_date" in payload:
|
||||
qd = str(payload.get("plan_date") or "").strip()[:10]
|
||||
if not qd:
|
||||
raise ValueError("缺少 plan_date")
|
||||
fields["plan_date"] = qd
|
||||
if "exchange_key" in payload:
|
||||
ex = str(payload.get("exchange_key") or "").strip().lower()
|
||||
if not ex:
|
||||
raise ValueError("缺少 exchange_key")
|
||||
fields["exchange_key"] = ex
|
||||
if "symbol" in payload:
|
||||
fields["symbol"] = normalize_plan_symbol(payload.get("symbol") or "")
|
||||
if "plan_type" in payload:
|
||||
fields["plan_type"] = _validate_choice(payload.get("plan_type"), PLAN_TYPES, "类型")
|
||||
if "trend_timeframe" in payload:
|
||||
fields["trend_timeframe"] = _validate_choice(
|
||||
payload.get("trend_timeframe"), TREND_TIMEFRAMES, "趋势周期"
|
||||
)
|
||||
if "entry_timeframe" in payload:
|
||||
fields["entry_timeframe"] = _validate_choice(
|
||||
payload.get("entry_timeframe"), ENTRY_TIMEFRAMES, "入场周期"
|
||||
)
|
||||
if "direction" in payload:
|
||||
fields["direction"] = _validate_choice(payload.get("direction"), DIRECTIONS, "方向")
|
||||
if "entry_scheme" in payload:
|
||||
fields["entry_scheme"] = _validate_choice(
|
||||
payload.get("entry_scheme"), ENTRY_SCHEMES, "入场方案"
|
||||
)
|
||||
if "target_level" in payload:
|
||||
fields["target_level"] = str(payload.get("target_level") or "").strip()
|
||||
if "current_range" in payload:
|
||||
fields["current_range"] = str(payload.get("current_range") or "").strip()
|
||||
if "note" in payload:
|
||||
fields["note"] = str(payload.get("note") or "").strip()
|
||||
if "pnl_amount" in payload:
|
||||
fields["pnl_amount"] = _parse_optional_pnl(payload.get("pnl_amount"))
|
||||
archive_now = False
|
||||
if "result" in payload:
|
||||
res_raw = payload.get("result")
|
||||
if res_raw is None or str(res_raw).strip() == "":
|
||||
fields["result"] = None
|
||||
else:
|
||||
fields["result"] = _validate_choice(res_raw, RESULTS, "结果")
|
||||
archive_now = True
|
||||
if not fields:
|
||||
return _row_to_dict(row)
|
||||
now = _now_ms()
|
||||
fields["updated_at"] = now
|
||||
if archive_now:
|
||||
scheme_val = fields.get("entry_scheme", row["entry_scheme"])
|
||||
if not str(scheme_val or "").strip():
|
||||
raise ValueError("归档前请在进行中计划里选择入场方案")
|
||||
fields["status"] = "archived"
|
||||
fields["archived_at"] = now
|
||||
sets = ", ".join(f"{k}=?" for k in fields)
|
||||
conn.execute(
|
||||
f"UPDATE entry_plans SET {sets} WHERE id=?",
|
||||
(*fields.values(), int(plan_id)),
|
||||
)
|
||||
updated = conn.execute("SELECT * FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||
return _row_to_dict(updated)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_entry_plan(plan_id: int, *, db_path: Path | None = None) -> bool:
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT status FROM entry_plans WHERE id=?", (int(plan_id),)).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] != "active":
|
||||
raise ValueError("仅进行中的计划可删除")
|
||||
cur = conn.execute("DELETE FROM entry_plans WHERE id=? AND status='active'", (int(plan_id),))
|
||||
return int(cur.rowcount or 0) > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
return datetime.now(DISPLAY_TZ).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def resolve_stats_date_bounds(
|
||||
*,
|
||||
period: str = "all",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
) -> tuple[str | None, str | None, str]:
|
||||
"""返回 (date_from, date_to, label);all 时 bounds 为 None."""
|
||||
p = (period or "all").strip().lower() or "all"
|
||||
today = _today_iso()
|
||||
if p == "all":
|
||||
return None, None, "全部历史"
|
||||
if p == "week":
|
||||
day_dt = datetime.strptime(today, "%Y-%m-%d")
|
||||
monday = (day_dt - timedelta(days=day_dt.weekday())).strftime("%Y-%m-%d")
|
||||
return monday, today, f"本周 {monday}~{today}"
|
||||
if p == "month":
|
||||
day_dt = datetime.strptime(today, "%Y-%m-%d")
|
||||
first = day_dt.replace(day=1).strftime("%Y-%m-%d")
|
||||
return first, today, f"本月 {first}~{today}"
|
||||
if p == "range":
|
||||
df = (date_from or "").strip()[:10] or today
|
||||
dt = (date_to or "").strip()[:10] or df
|
||||
if df > dt:
|
||||
df, dt = dt, df
|
||||
label = f"区间 {df}~{dt}" if df != dt else f"区间 {df}"
|
||||
return df, dt, label
|
||||
return None, None, "全部历史"
|
||||
|
||||
|
||||
def compute_entry_plan_stats(
|
||||
*,
|
||||
dimension: str = "symbol",
|
||||
period: str = "all",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_db(db_path)
|
||||
dim = (dimension or "symbol").strip().lower()
|
||||
if dim not in STAT_DIMENSIONS:
|
||||
raise ValueError("dimension 无效")
|
||||
df_bound, dt_bound, period_label = resolve_stats_date_bounds(
|
||||
period=period, date_from=date_from, date_to=date_to
|
||||
)
|
||||
col_map = {
|
||||
"symbol": "symbol",
|
||||
"trend_tf": "trend_timeframe",
|
||||
"entry_scheme": "entry_scheme",
|
||||
}
|
||||
col = col_map[dim]
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
where = "status='archived' AND result IN ('win','loss')"
|
||||
params: list[Any] = []
|
||||
if df_bound:
|
||||
where += " AND plan_date >= ? AND plan_date <= ?"
|
||||
params.extend([df_bound, dt_bound])
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT {col} AS dim_key,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN result='win' THEN 1 ELSE 0 END) AS win_count,
|
||||
SUM(CASE WHEN result='loss' THEN 1 ELSE 0 END) AS loss_count
|
||||
FROM entry_plans
|
||||
WHERE {where}
|
||||
GROUP BY {col}
|
||||
ORDER BY total DESC, dim_key ASC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
total = int(r["total"] or 0)
|
||||
wins = int(r["win_count"] or 0)
|
||||
losses = int(r["loss_count"] or 0)
|
||||
key = str(r["dim_key"] or "")
|
||||
label = key
|
||||
if dim == "entry_scheme":
|
||||
label = ENTRY_SCHEMES.get(key, key)
|
||||
elif dim == "trend_tf":
|
||||
label = key
|
||||
win_rate = round(wins / total * 100, 1) if total else None
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": label,
|
||||
"total": total,
|
||||
"win_count": wins,
|
||||
"loss_count": losses,
|
||||
"win_rate": win_rate,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"dimension": dim,
|
||||
"period": period,
|
||||
"period_label": period_label,
|
||||
"date_from": df_bound,
|
||||
"date_to": dt_bound,
|
||||
"items": items,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def meta_payload(exchanges: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"plan_types": [{"value": k, "label": v} for k, v in PLAN_TYPES.items()],
|
||||
"trend_timeframes": list(TREND_TIMEFRAMES),
|
||||
"entry_timeframes": list(ENTRY_TIMEFRAMES),
|
||||
"directions": [{"value": k, "label": v} for k, v in DIRECTIONS.items()],
|
||||
"entry_schemes": [{"value": k, "label": v} for k, v in ENTRY_SCHEMES.items()],
|
||||
"results": [{"value": k, "label": v} for k, v in RESULTS.items()],
|
||||
"stat_dimensions": [
|
||||
{"value": "symbol", "label": "币种"},
|
||||
{"value": "trend_tf", "label": "趋势周期"},
|
||||
{"value": "entry_scheme", "label": "入场方案"},
|
||||
],
|
||||
"exchanges": exchanges or [],
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
"""中控资金概况:分户日快照(180 交易日),总资金曲线与回撤."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.hub.hub_trades_lib import current_trading_day
|
||||
from lib.hub.hub_options_funds_lib import merge_board_row_balances, repair_double_counted_fund_entry
|
||||
|
||||
from lib.paths import manual_trading_hub_dir
|
||||
|
||||
HUB_DIR = manual_trading_hub_dir()
|
||||
FUND_HISTORY_PATH = HUB_DIR / "hub_fund_history.json"
|
||||
LEGACY_FUND_HISTORY_PATH = HUB_DIR / "hub_ai_fund_history.json"
|
||||
|
||||
try:
|
||||
FUND_HISTORY_DAYS = max(30, int(os.getenv("HUB_FUND_HISTORY_DAYS", "180") or "180"))
|
||||
except ValueError:
|
||||
FUND_HISTORY_DAYS = 180
|
||||
|
||||
FUND_HISTORY_START_DAY = (os.getenv("HUB_FUND_HISTORY_START_DAY") or "2026-06-09").strip()[:10]
|
||||
|
||||
|
||||
def fund_history_start_day() -> str:
|
||||
return FUND_HISTORY_START_DAY or "2026-06-09"
|
||||
|
||||
|
||||
def _now_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
v = float(value)
|
||||
return v if v >= 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def account_total_usdt(funding: Any, trading: Any) -> Optional[float]:
|
||||
"""资金户 + 交易户;任一侧缺失则不计入(返回 None)."""
|
||||
fu = _safe_float(funding)
|
||||
tu = _safe_float(trading)
|
||||
if fu is None or tu is None:
|
||||
return None
|
||||
return round(fu + tu, 4)
|
||||
|
||||
|
||||
def compute_drawdown(values: list[float]) -> dict[str, Any]:
|
||||
"""基于资金权益序列计算峰值回撤(U 与 %)."""
|
||||
peak = 0.0
|
||||
max_dd_u = 0.0
|
||||
peak_at_end = 0.0
|
||||
for v in values:
|
||||
if not isinstance(v, (int, float)):
|
||||
continue
|
||||
fv = float(v)
|
||||
if fv > peak:
|
||||
peak = fv
|
||||
dd = peak - fv
|
||||
if dd > max_dd_u:
|
||||
max_dd_u = dd
|
||||
peak_at_end = peak
|
||||
max_dd_u = round(max_dd_u, 4)
|
||||
peak_at_end = round(peak_at_end, 4)
|
||||
max_dd_pct = round((max_dd_u / peak_at_end) * 100, 2) if peak_at_end > 0 else None
|
||||
return {
|
||||
"peak_usdt": peak_at_end,
|
||||
"max_drawdown_u": max_dd_u,
|
||||
"max_drawdown_pct": max_dd_pct,
|
||||
}
|
||||
|
||||
|
||||
def compute_period_delta(series: list[dict]) -> dict[str, Any]:
|
||||
"""相对曲线起点的资金变动(U 与 %);含出入金影响,口径同权益曲线."""
|
||||
pts = [
|
||||
p
|
||||
for p in (series or [])
|
||||
if isinstance(p, dict) and isinstance(p.get("total_usdt"), (int, float))
|
||||
]
|
||||
if not pts:
|
||||
return {
|
||||
"start_usdt": None,
|
||||
"period_delta_usdt": None,
|
||||
"period_delta_pct": None,
|
||||
}
|
||||
start = round(float(pts[0]["total_usdt"]), 4)
|
||||
end = round(float(pts[-1]["total_usdt"]), 4)
|
||||
delta = round(end - start, 4)
|
||||
pct = round((delta / start) * 100, 2) if start > 0 else None
|
||||
return {
|
||||
"start_usdt": start,
|
||||
"period_delta_usdt": delta,
|
||||
"period_delta_pct": pct,
|
||||
}
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _prune_days(
|
||||
days: dict,
|
||||
*,
|
||||
keep_days: int,
|
||||
anchor_day: str,
|
||||
start_day: Optional[str] = None,
|
||||
) -> dict:
|
||||
try:
|
||||
anchor = datetime.strptime(anchor_day[:10], "%Y-%m-%d")
|
||||
except ValueError:
|
||||
anchor = datetime.now()
|
||||
rolling_cutoff = (anchor - timedelta(days=max(1, keep_days) - 1)).strftime("%Y-%m-%d")
|
||||
start = (start_day or fund_history_start_day()).strip()[:10]
|
||||
cutoff = max(rolling_cutoff, start) if start else rolling_cutoff
|
||||
return {k: v for k, v in (days or {}).items() if str(k) >= cutoff}
|
||||
|
||||
|
||||
def _migrate_legacy_store(days: dict) -> dict:
|
||||
if not LEGACY_FUND_HISTORY_PATH.is_file():
|
||||
return days
|
||||
try:
|
||||
loaded = json.loads(LEGACY_FUND_HISTORY_PATH.read_text(encoding="utf-8"))
|
||||
legacy_days = loaded.get("days") if isinstance(loaded, dict) else {}
|
||||
if not isinstance(legacy_days, dict):
|
||||
return days
|
||||
merged = dict(days)
|
||||
for day, block in legacy_days.items():
|
||||
if day in merged:
|
||||
continue
|
||||
if isinstance(block, dict) and block.get("accounts"):
|
||||
merged[day] = block
|
||||
return merged
|
||||
except Exception:
|
||||
return days
|
||||
|
||||
|
||||
def _load_store() -> dict:
|
||||
if not FUND_HISTORY_PATH.is_file():
|
||||
store = {"version": 1, "days": _migrate_legacy_store({})}
|
||||
if store["days"]:
|
||||
_atomic_write(FUND_HISTORY_PATH, store)
|
||||
return store
|
||||
try:
|
||||
loaded = json.loads(FUND_HISTORY_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(loaded, dict):
|
||||
loaded.setdefault("version", 1)
|
||||
days = dict(loaded.get("days") or {})
|
||||
loaded["days"] = _migrate_legacy_store(days)
|
||||
return loaded
|
||||
except Exception:
|
||||
pass
|
||||
return {"version": 1, "days": {}}
|
||||
|
||||
|
||||
def record_fund_snapshot(
|
||||
trading_day: str,
|
||||
accounts: list[dict],
|
||||
*,
|
||||
keep_days: int = FUND_HISTORY_DAYS,
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""写入当日各户资金账户/交易账户余额,并裁剪历史."""
|
||||
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
|
||||
start = fund_history_start_day()
|
||||
if start and day < start:
|
||||
return _load_store().get("days") or {}
|
||||
store = _load_store()
|
||||
days = dict(store.get("days") or {})
|
||||
row_accounts: dict[str, dict] = {}
|
||||
for ac in accounts or []:
|
||||
key = str(ac.get("key") or ac.get("id") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
if not ac.get("monitored"):
|
||||
continue
|
||||
fu = _safe_float(ac.get("funding_usdt"))
|
||||
tu = _safe_float(ac.get("trading_usdt"))
|
||||
total = account_total_usdt(fu, tu)
|
||||
if total is None:
|
||||
continue
|
||||
entry: dict[str, Any] = {
|
||||
"name": ac.get("name"),
|
||||
"funding_usdt": fu,
|
||||
"trading_usdt": tu,
|
||||
"total_usdt": total,
|
||||
"recorded_at": _now_str(),
|
||||
}
|
||||
ofu = _safe_float(ac.get("options_funding_usdt"))
|
||||
otu = _safe_float(ac.get("options_trading_usdt"))
|
||||
if ofu is not None:
|
||||
entry["options_funding_usdt"] = ofu
|
||||
if otu is not None:
|
||||
entry["options_trading_usdt"] = otu
|
||||
row_accounts[key] = entry
|
||||
if row_accounts:
|
||||
days[day] = {"accounts": row_accounts, "updated_at": _now_str()}
|
||||
days = _prune_days(
|
||||
days, keep_days=keep_days, anchor_day=day, start_day=fund_history_start_day()
|
||||
)
|
||||
_atomic_write(FUND_HISTORY_PATH, {"version": 1, "days": days})
|
||||
return days
|
||||
|
||||
|
||||
def record_fund_snapshot_from_board(
|
||||
rows: list[dict],
|
||||
*,
|
||||
keep_days: int = FUND_HISTORY_DAYS,
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""监控板行写入当日快照(仅 account_ok 且资金/交易户齐全)."""
|
||||
day = current_trading_day(reset_hour=reset_hour)
|
||||
accounts = []
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
if not row.get("account_ok") and not (
|
||||
"options" in (row.get("capabilities") or [])
|
||||
and isinstance(row.get("options"), dict)
|
||||
and row.get("options", {}).get("ok")
|
||||
):
|
||||
continue
|
||||
merged = merge_board_row_balances(row)
|
||||
if not merged.get("data_ok"):
|
||||
continue
|
||||
accounts.append(
|
||||
{
|
||||
"key": row.get("key") or row.get("id"),
|
||||
"name": row.get("name"),
|
||||
"funding_usdt": merged.get("funding_usdt"),
|
||||
"trading_usdt": merged.get("trading_usdt"),
|
||||
"options_funding_usdt": merged.get("options_funding_usdt"),
|
||||
"options_trading_usdt": merged.get("options_trading_usdt"),
|
||||
"monitored": True,
|
||||
}
|
||||
)
|
||||
return record_fund_snapshot(day, accounts, keep_days=keep_days, reset_hour=reset_hour)
|
||||
|
||||
|
||||
def get_fund_history(*, anchor_day: str, keep_days: int = FUND_HISTORY_DAYS) -> dict[str, dict]:
|
||||
store = _load_store()
|
||||
return _prune_days(
|
||||
dict(store.get("days") or {}),
|
||||
keep_days=keep_days,
|
||||
anchor_day=anchor_day,
|
||||
start_day=fund_history_start_day(),
|
||||
)
|
||||
|
||||
|
||||
def _exchange_monitored(ex: dict) -> bool:
|
||||
return bool(ex.get("enabled")) and not bool(ex.get("env_disabled"))
|
||||
|
||||
|
||||
def _live_row_for_exchange(ex: dict, rows_by_key: dict[str, dict]) -> Optional[dict]:
|
||||
key = str(ex.get("key") or "").strip()
|
||||
if not key:
|
||||
return None
|
||||
return rows_by_key.get(key)
|
||||
|
||||
|
||||
def _series_from_history(
|
||||
history: dict[str, dict],
|
||||
account_keys: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for day in sorted(history.keys()):
|
||||
block = history.get(day) or {}
|
||||
ac_map = block.get("accounts") or {}
|
||||
total = 0.0
|
||||
n = 0
|
||||
for key in account_keys:
|
||||
ac = repair_double_counted_fund_entry(ac_map.get(key) or {})
|
||||
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
|
||||
if t is None:
|
||||
t = _safe_float(ac.get("total_usdt"))
|
||||
if t is None:
|
||||
continue
|
||||
total += t
|
||||
n += 1
|
||||
if n > 0:
|
||||
out.append({"day": day, "total_usdt": round(total, 4)})
|
||||
return out
|
||||
|
||||
|
||||
def _account_series(history: dict[str, dict], key: str) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for day in sorted(history.keys()):
|
||||
raw = (history.get(day) or {}).get("accounts", {}).get(key) or {}
|
||||
ac = repair_double_counted_fund_entry(raw)
|
||||
t = account_total_usdt(ac.get("funding_usdt"), ac.get("trading_usdt"))
|
||||
if t is None:
|
||||
t = _safe_float(ac.get("total_usdt"))
|
||||
if t is None:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"day": day,
|
||||
"total_usdt": t,
|
||||
"funding_usdt": _safe_float(ac.get("funding_usdt")),
|
||||
"trading_usdt": _safe_float(ac.get("trading_usdt")),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def build_fund_overview(
|
||||
exchanges: list[dict],
|
||||
*,
|
||||
board_rows: Optional[list[dict]] = None,
|
||||
trading_day: Optional[str] = None,
|
||||
keep_days: int = FUND_HISTORY_DAYS,
|
||||
reset_hour: int = 8,
|
||||
updated_at: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
day = (trading_day or "").strip()[:10] or current_trading_day(reset_hour=reset_hour)
|
||||
history = get_fund_history(anchor_day=day, keep_days=keep_days)
|
||||
rows_by_key: dict[str, dict] = {}
|
||||
for row in board_rows or []:
|
||||
if isinstance(row, dict):
|
||||
k = str(row.get("key") or "").strip()
|
||||
if k:
|
||||
rows_by_key[k] = row
|
||||
|
||||
monitored_keys: list[str] = []
|
||||
accounts_out: list[dict[str, Any]] = []
|
||||
live_total = 0.0
|
||||
live_known = 0
|
||||
|
||||
for ex in exchanges or []:
|
||||
if not _exchange_monitored(ex):
|
||||
continue
|
||||
key = str(ex.get("key") or "").strip()
|
||||
monitored = True
|
||||
row = _live_row_for_exchange(ex, rows_by_key)
|
||||
fu = tu = total = None
|
||||
pf = pt = ofu = otu = None
|
||||
data_ok = False
|
||||
caps = ex.get("capabilities") or []
|
||||
if row:
|
||||
merged = merge_board_row_balances({**row, "capabilities": caps})
|
||||
if merged.get("data_ok"):
|
||||
fu = merged.get("funding_usdt")
|
||||
tu = merged.get("trading_usdt")
|
||||
total = merged.get("total_usdt")
|
||||
pf = merged.get("perpetual_funding_usdt")
|
||||
pt = merged.get("perpetual_trading_usdt")
|
||||
ofu = merged.get("options_funding_usdt")
|
||||
otu = merged.get("options_trading_usdt")
|
||||
data_ok = True
|
||||
live_total += float(total)
|
||||
live_known += 1
|
||||
|
||||
series = _account_series(history, key) if key else []
|
||||
dd = compute_drawdown([p["total_usdt"] for p in series]) if series else {
|
||||
"peak_usdt": None,
|
||||
"max_drawdown_u": None,
|
||||
"max_drawdown_pct": None,
|
||||
}
|
||||
day_delta = None
|
||||
if series:
|
||||
if len(series) >= 2:
|
||||
day_delta = round(series[-1]["total_usdt"] - series[-2]["total_usdt"], 4)
|
||||
elif data_ok and total is not None:
|
||||
day_delta = round(total - series[-1]["total_usdt"], 4)
|
||||
period = compute_period_delta(series)
|
||||
|
||||
accounts_out.append(
|
||||
{
|
||||
"id": ex.get("id"),
|
||||
"key": key,
|
||||
"name": ex.get("name") or key,
|
||||
"monitored": monitored,
|
||||
"data_ok": data_ok,
|
||||
"funding_usdt": fu,
|
||||
"trading_usdt": tu,
|
||||
"perpetual_funding_usdt": pf,
|
||||
"perpetual_trading_usdt": pt,
|
||||
"options_funding_usdt": ofu,
|
||||
"options_trading_usdt": otu,
|
||||
"total_usdt": total,
|
||||
"series": series,
|
||||
"drawdown": dd,
|
||||
"day_delta_usdt": day_delta,
|
||||
"start_usdt": period["start_usdt"],
|
||||
"period_delta_usdt": period["period_delta_usdt"],
|
||||
"period_delta_pct": period["period_delta_pct"],
|
||||
}
|
||||
)
|
||||
if key:
|
||||
monitored_keys.append(key)
|
||||
|
||||
total_series = _series_from_history(history, monitored_keys)
|
||||
if live_known > 0:
|
||||
last_day = total_series[-1]["day"] if total_series else None
|
||||
live_point = round(live_total, 4)
|
||||
if last_day == day and total_series:
|
||||
total_series[-1]["total_usdt"] = live_point
|
||||
total_series[-1]["live"] = True
|
||||
else:
|
||||
total_series.append({"day": day, "total_usdt": live_point, "live": True})
|
||||
|
||||
total_dd = compute_drawdown([p["total_usdt"] for p in total_series]) if total_series else {
|
||||
"peak_usdt": None,
|
||||
"max_drawdown_u": None,
|
||||
"max_drawdown_pct": None,
|
||||
}
|
||||
total_day_delta = None
|
||||
if total_series:
|
||||
if len(total_series) >= 2:
|
||||
total_day_delta = round(
|
||||
total_series[-1]["total_usdt"] - total_series[-2]["total_usdt"], 4
|
||||
)
|
||||
total_period = compute_period_delta(total_series)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"trading_day": day,
|
||||
"reset_hour": reset_hour,
|
||||
"keep_days": keep_days,
|
||||
"history_start_day": fund_history_start_day(),
|
||||
"updated_at": updated_at,
|
||||
"totals": {
|
||||
"monitored_count": len(monitored_keys),
|
||||
"live_known_count": live_known,
|
||||
"total_usdt": round(live_total, 4) if live_known > 0 else None,
|
||||
"day_delta_usdt": total_day_delta,
|
||||
"start_usdt": total_period["start_usdt"],
|
||||
"period_delta_usdt": total_period["period_delta_usdt"],
|
||||
"period_delta_pct": total_period["period_delta_pct"],
|
||||
"series": total_series,
|
||||
"drawdown": total_dd,
|
||||
},
|
||||
"accounts": accounts_out,
|
||||
}
|
||||
|
||||
|
||||
def format_fund_history_text(
|
||||
history: dict[str, dict],
|
||||
*,
|
||||
account_names: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
if not history:
|
||||
return "(暂无资金历史快照)"
|
||||
names = account_names or {}
|
||||
lines = ["【资金快照(资金账户 + 交易账户 USDT,含期权 USDC≈USDT)】"]
|
||||
for day in sorted(history.keys()):
|
||||
block = history.get(day) or {}
|
||||
ac_map = block.get("accounts") or {}
|
||||
if not ac_map:
|
||||
continue
|
||||
parts = []
|
||||
for key, ac in ac_map.items():
|
||||
label = names.get(key) or ac.get("name") or key
|
||||
fu = ac.get("funding_usdt")
|
||||
tu = ac.get("trading_usdt")
|
||||
tot = ac.get("total_usdt")
|
||||
if tot is None:
|
||||
tot = account_total_usdt(fu, tu)
|
||||
fu_txt = f"{fu}U" if fu is not None else "未知"
|
||||
tu_txt = f"{tu}U" if tu is not None else "未知"
|
||||
tot_txt = f"{tot}U" if tot is not None else "未知"
|
||||
parts.append(f"{label}: 合计{tot_txt}(资金{fu_txt}/交易{tu_txt})")
|
||||
lines.append(f"- {day}: " + ";".join(parts))
|
||||
return "\n".join(lines) if len(lines) > 1 else "(暂无资金历史快照)"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""中控「使用说明」:读取 manual_trading_hub/docs/help 下的 MD."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.hub.hub_strategy_lib import render_markdown_html
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
HELP_SECTIONS: tuple[dict[str, str], ...] = (
|
||||
{"key": "quickstart", "label": "快速开始", "title": "快速开始", "md_file": "01-quickstart.md"},
|
||||
{"key": "hub-nav", "label": "中控导航", "title": "中控导航说明", "md_file": "02-hub-nav.md"},
|
||||
{"key": "monitor", "label": "监控区", "title": "监控区与实例入口", "md_file": "03-monitor.md"},
|
||||
{"key": "instance", "label": "实例页面", "title": "实例页导航说明", "md_file": "04-instance.md"},
|
||||
{"key": "settings", "label": "设置说明", "title": "设置与配置说明", "md_file": "05-settings.md"},
|
||||
)
|
||||
|
||||
|
||||
def _help_dir() -> Path:
|
||||
return REPO_ROOT / "manual_trading_hub" / "docs" / "help"
|
||||
|
||||
|
||||
def _section_meta(key: str) -> dict[str, str]:
|
||||
k = (key or "").strip().lower()
|
||||
for item in HELP_SECTIONS:
|
||||
if item["key"] == k:
|
||||
return item
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def _md_path(section_key: str) -> Path:
|
||||
return _help_dir() / _section_meta(section_key)["md_file"]
|
||||
|
||||
|
||||
def help_meta_payload() -> dict[str, Any]:
|
||||
sections = [{"key": s["key"], "label": s["label"], "title": s["title"]} for s in HELP_SECTIONS]
|
||||
return {"ok": True, "sections": sections}
|
||||
|
||||
|
||||
def load_help_payload(section_key: str) -> dict[str, Any]:
|
||||
key = (section_key or "").strip().lower()
|
||||
meta = _section_meta(key)
|
||||
md_path = _md_path(key)
|
||||
md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else ""
|
||||
return {
|
||||
"ok": True,
|
||||
"section_key": key,
|
||||
"label": meta["label"],
|
||||
"title": meta["title"],
|
||||
"md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"),
|
||||
"content_html": render_markdown_html(md_text),
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
_state: dict[str, Any] = {
|
||||
"primed": False,
|
||||
"net_ts": 0.0,
|
||||
"net_sent": 0,
|
||||
"net_recv": 0,
|
||||
}
|
||||
|
||||
|
||||
def _disk_path() -> str:
|
||||
raw = (os.getenv("HUB_HOST_DISK_PATH") or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if os.name == "nt":
|
||||
drive = (os.environ.get("SystemDrive") or "C:").strip()
|
||||
return drive if drive.endswith(("\\", "/")) else drive + "\\"
|
||||
return "/"
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def get_host_status() -> dict[str, Any]:
|
||||
try:
|
||||
import psutil
|
||||
except ImportError:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil",
|
||||
}
|
||||
|
||||
now = time.time()
|
||||
if not _state["primed"]:
|
||||
psutil.cpu_percent(interval=None)
|
||||
_state["primed"] = True
|
||||
|
||||
cpu_pct = float(psutil.cpu_percent(interval=None))
|
||||
cpu_count = int(psutil.cpu_count(logical=True) or 0)
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
disk_path = _disk_path()
|
||||
du = psutil.disk_usage(disk_path)
|
||||
|
||||
net = psutil.net_io_counters()
|
||||
sent_rate = 0.0
|
||||
recv_rate = 0.0
|
||||
if net is not None and _state["net_ts"] > 0:
|
||||
dt = max(0.001, now - float(_state["net_ts"]))
|
||||
sent_rate = max(0.0, (net.bytes_sent - int(_state["net_sent"])) / dt)
|
||||
recv_rate = max(0.0, (net.bytes_recv - int(_state["net_recv"])) / dt)
|
||||
if net is not None:
|
||||
_state["net_ts"] = now
|
||||
_state["net_sent"] = int(net.bytes_sent)
|
||||
_state["net_recv"] = int(net.bytes_recv)
|
||||
|
||||
disk_total = _safe_int(du.total)
|
||||
disk_used = _safe_int(du.used)
|
||||
disk_pct = round(disk_used / disk_total * 100, 1) if disk_total > 0 else 0.0
|
||||
|
||||
boot = float(psutil.boot_time())
|
||||
return {
|
||||
"ok": True,
|
||||
"hostname": socket.gethostname(),
|
||||
"uptime_sec": max(0, int(now - boot)),
|
||||
"cpu": {
|
||||
"percent": round(cpu_pct, 1),
|
||||
"count": cpu_count,
|
||||
},
|
||||
"memory": {
|
||||
"total_bytes": _safe_int(vm.total),
|
||||
"used_bytes": _safe_int(vm.used),
|
||||
"percent": round(float(vm.percent), 1),
|
||||
},
|
||||
"disk": {
|
||||
"path": disk_path,
|
||||
"total_bytes": disk_total,
|
||||
"used_bytes": disk_used,
|
||||
"percent": disk_pct,
|
||||
},
|
||||
"network": {
|
||||
"bytes_sent": _safe_int(net.bytes_sent if net else 0),
|
||||
"bytes_recv": _safe_int(net.bytes_recv if net else 0),
|
||||
"sent_rate_bps": round(sent_rate, 1),
|
||||
"recv_rate_bps": round(recv_rate, 1),
|
||||
},
|
||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
@@ -0,0 +1,881 @@
|
||||
"""中控 K 线 SQLite:分周期保留,交易所直拉,分页读取."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.hub.hub_ohlcv_lib import (
|
||||
HUB_KLINE_1M_MAX_BARS,
|
||||
HUB_KLINE_5M_1H_RETENTION_DAYS,
|
||||
TIMEFRAME_MS,
|
||||
YEAR_ROLLING_STORED,
|
||||
chart_chunk_limit,
|
||||
chart_initial_limit,
|
||||
chart_memory_cap,
|
||||
history_cutoff_ms_for_storage,
|
||||
normalize_chart_timeframe,
|
||||
normalize_price_tick,
|
||||
format_price_by_tick,
|
||||
last_closed_bar_open_ms,
|
||||
retention_policy_meta,
|
||||
round_ohlcv_bars_to_tick,
|
||||
seed_bar_target,
|
||||
)
|
||||
|
||||
HUB_KLINE_MIN_BARS_BEFORE_TAIL = 200
|
||||
HUB_KLINE_REMOTE_FETCH_CAP = 1500
|
||||
|
||||
_DEFAULT_RETENTION_DAYS = 15
|
||||
|
||||
|
||||
def retention_days() -> int:
|
||||
"""兼容旧配置;新策略见 retention_policy_meta."""
|
||||
try:
|
||||
return max(1, int(os.getenv("HUB_KLINE_RETENTION_DAYS", str(_DEFAULT_RETENTION_DAYS))))
|
||||
except ValueError:
|
||||
return _DEFAULT_RETENTION_DAYS
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
raw = (os.getenv("HUB_KLINE_DB_PATH") or "").strip()
|
||||
if raw:
|
||||
return Path(raw)
|
||||
from lib.paths import hub_data_dir
|
||||
|
||||
return hub_data_dir() / "hub_kline.db"
|
||||
|
||||
|
||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
path = db_path or default_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: Path | None = None) -> None:
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ohlcv_bars (
|
||||
exchange_key TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL,
|
||||
open_time_ms INTEGER NOT NULL,
|
||||
open REAL NOT NULL,
|
||||
high REAL NOT NULL,
|
||||
low REAL NOT NULL,
|
||||
close REAL NOT NULL,
|
||||
volume REAL NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (exchange_key, symbol, timeframe, open_time_ms)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_ohlcv_series
|
||||
ON ohlcv_bars (exchange_key, symbol, timeframe, open_time_ms)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ohlcv_symbol_meta (
|
||||
exchange_key TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
price_tick REAL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (exchange_key, symbol)
|
||||
)
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_symbol_price_tick(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
price_tick: float | None,
|
||||
db_path: Path | None = None,
|
||||
) -> None:
|
||||
tick = price_tick
|
||||
if tick is None:
|
||||
return
|
||||
try:
|
||||
t = float(tick)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if t <= 0:
|
||||
return
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ohlcv_symbol_meta (exchange_key, symbol, price_tick, updated_at)
|
||||
VALUES (?,?,?,?)
|
||||
ON CONFLICT(exchange_key, symbol) DO UPDATE SET
|
||||
price_tick=excluded.price_tick,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(ex_k, sym, t, int(time.time())),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_symbol_price_tick(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
db_path: Path | None = None,
|
||||
) -> float | None:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT price_tick FROM ohlcv_symbol_meta WHERE exchange_key=? AND symbol=?",
|
||||
(ex_k, sym),
|
||||
).fetchone()
|
||||
if not row or row["price_tick"] is None:
|
||||
return None
|
||||
return float(row["price_tick"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purge_timeframe_by_days(
|
||||
timeframe: str,
|
||||
days: int,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
cutoff = int(time.time() * 1000) - max(1, int(days)) * 86400000
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM ohlcv_bars WHERE timeframe=? AND open_time_ms < ?",
|
||||
(tf, cutoff),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purge_1m_bar_cap(db_path: Path | None = None, *, max_bars: int | None = None) -> int:
|
||||
cap = max(100, int(max_bars or HUB_KLINE_1M_MAX_BARS))
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
DELETE FROM ohlcv_bars
|
||||
WHERE timeframe='1m' AND rowid IN (
|
||||
SELECT rowid FROM (
|
||||
SELECT rowid,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY exchange_key, symbol
|
||||
ORDER BY open_time_ms DESC
|
||||
) AS rn
|
||||
FROM ohlcv_bars
|
||||
WHERE timeframe='1m'
|
||||
) WHERE rn > ?
|
||||
)
|
||||
""",
|
||||
(cap,),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear_series_bars(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
"""删除某交易所+币种 K 线(可指定周期);用于清库后全量重拉."""
|
||||
init_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
if not ex_k or not sym:
|
||||
return 0
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
if timeframe:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
cur = conn.execute(
|
||||
"DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=? AND timeframe=?",
|
||||
(ex_k, sym, tf),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM ohlcv_bars WHERE exchange_key=? AND symbol=?",
|
||||
(ex_k, sym),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear_all_bars(db_path: Path | None = None) -> int:
|
||||
"""清空 hub K 线库全部 OHLCV 行."""
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM ohlcv_bars")
|
||||
return int(cur.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purge_retention(db_path: Path | None = None) -> int:
|
||||
"""按周期策略清理:5m/15m/1h/2h/4h 一年;1m 保留最近 N 根;1d/1w 不删."""
|
||||
n = 0
|
||||
for tf in sorted(YEAR_ROLLING_STORED):
|
||||
n += purge_timeframe_by_days(tf, HUB_KLINE_5M_1H_RETENTION_DAYS, db_path)
|
||||
n += purge_1m_bar_cap(db_path)
|
||||
return n
|
||||
|
||||
|
||||
def upsert_bars(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
bars: list[dict[str, Any]],
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
if not bars:
|
||||
return 0
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
now = int(time.time())
|
||||
conn = _connect(db_path)
|
||||
n = 0
|
||||
try:
|
||||
for b in bars:
|
||||
try:
|
||||
oms = int(b["open_time_ms"])
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ohlcv_bars
|
||||
(exchange_key, symbol, timeframe, open_time_ms, open, high, low, close, volume, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(exchange_key, symbol, timeframe, open_time_ms) DO UPDATE SET
|
||||
open=excluded.open,
|
||||
high=excluded.high,
|
||||
low=excluded.low,
|
||||
close=excluded.close,
|
||||
volume=excluded.volume,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
ex_k,
|
||||
sym,
|
||||
tf,
|
||||
oms,
|
||||
float(b["open"]),
|
||||
float(b["high"]),
|
||||
float(b["low"]),
|
||||
float(b["close"]),
|
||||
float(b.get("volume") or 0),
|
||||
now,
|
||||
),
|
||||
)
|
||||
n += 1
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
finally:
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
def load_bars_range(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
db_path: Path | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT open_time_ms, open, high, low, close, volume
|
||||
FROM ohlcv_bars
|
||||
WHERE exchange_key=? AND symbol=? AND timeframe=?
|
||||
AND open_time_ms >= ? AND open_time_ms <= ?
|
||||
ORDER BY open_time_ms ASC
|
||||
""",
|
||||
(ex_k, sym, tf, int(start_ms), int(end_ms)),
|
||||
).fetchall()
|
||||
return _rows_to_bars(rows)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def count_series_bars(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS c FROM ohlcv_bars
|
||||
WHERE exchange_key=? AND symbol=? AND timeframe=?
|
||||
""",
|
||||
(ex_k, sym, tf),
|
||||
).fetchone()
|
||||
return int(row["c"] or 0) if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _remote_fetch_limit(
|
||||
*,
|
||||
need: int,
|
||||
force_refresh: bool,
|
||||
storage_tf: str,
|
||||
tail_only: bool,
|
||||
) -> int:
|
||||
if tail_only:
|
||||
return min(need + 20, 300)
|
||||
cap = HUB_KLINE_REMOTE_FETCH_CAP
|
||||
if force_refresh:
|
||||
return min(seed_bar_target(storage_tf), cap)
|
||||
return min(max(need + 20, 1), cap)
|
||||
|
||||
|
||||
def _since_ms_for_span(
|
||||
*,
|
||||
now_ms: int,
|
||||
period_ms: int,
|
||||
span_bars: int,
|
||||
cutoff_ms: int,
|
||||
) -> int:
|
||||
"""拉取窗口起点:跨度必须与 fetch_limit 一致,保证数据能铺到最近."""
|
||||
span = max(1, int(span_bars))
|
||||
return max(int(cutoff_ms), int(now_ms) - int(period_ms) * span)
|
||||
|
||||
|
||||
def load_bars_latest(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
limit: int,
|
||||
db_path: Path | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
lim = max(1, int(limit))
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT open_time_ms, open, high, low, close, volume
|
||||
FROM ohlcv_bars
|
||||
WHERE exchange_key=? AND symbol=? AND timeframe=?
|
||||
ORDER BY open_time_ms DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(ex_k, sym, tf, lim),
|
||||
).fetchall()
|
||||
return list(reversed(_rows_to_bars(rows)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_bars_before(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
before_ms: int,
|
||||
limit: int,
|
||||
db_path: Path | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
lim = max(1, int(limit))
|
||||
bms = int(before_ms)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT open_time_ms, open, high, low, close, volume
|
||||
FROM ohlcv_bars
|
||||
WHERE exchange_key=? AND symbol=? AND timeframe=?
|
||||
AND open_time_ms < ?
|
||||
ORDER BY open_time_ms DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(ex_k, sym, tf, bms, lim),
|
||||
).fetchall()
|
||||
return list(reversed(_rows_to_bars(rows)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def trim_contiguous_tail(
|
||||
bars: list[dict[str, Any]],
|
||||
period_ms: int,
|
||||
*,
|
||||
max_gap_factor: float = 3.0,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""只保留最近一段连续 K 线,丢弃左侧与主段断开的孤立数据."""
|
||||
if len(bars) <= 1:
|
||||
return list(bars), 0
|
||||
try:
|
||||
period = max(1, int(period_ms))
|
||||
except (TypeError, ValueError):
|
||||
period = 60_000
|
||||
max_gap = int(period * max_gap_factor)
|
||||
split = 0
|
||||
for i in range(len(bars) - 1, 0, -1):
|
||||
gap = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"])
|
||||
if gap > max_gap:
|
||||
split = i
|
||||
break
|
||||
return bars[split:], split
|
||||
|
||||
|
||||
def normalize_contiguous_db_rows(
|
||||
bars: list[dict[str, Any]],
|
||||
*,
|
||||
period_ms: int,
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
db_path: Path | None = None,
|
||||
purge_orphans: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""去掉与主段断开的孤立前缀;可选同步清理库内孤立数据."""
|
||||
if len(bars) <= 1:
|
||||
return list(bars)
|
||||
trimmed, split_at = trim_contiguous_tail(bars, period_ms)
|
||||
if split_at > 0 and purge_orphans:
|
||||
purge_bars_open_before(
|
||||
exchange_key,
|
||||
symbol,
|
||||
timeframe,
|
||||
int(trimmed[0]["open_time_ms"]),
|
||||
db_path,
|
||||
)
|
||||
return trimmed
|
||||
|
||||
|
||||
def purge_bars_open_before(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
open_time_ms: int,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
"""删除某品种周期下早于 open_time_ms 的 K 线(清理与主段断开的孤立历史)."""
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
sym = (symbol or "").strip().upper()
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
DELETE FROM ohlcv_bars
|
||||
WHERE exchange_key=? AND symbol=? AND timeframe=? AND open_time_ms < ?
|
||||
""",
|
||||
(ex_k, sym, tf, int(open_time_ms)),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _rows_to_bars(rows) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"open_time_ms": int(r["open_time_ms"]),
|
||||
"open": float(r["open"]),
|
||||
"high": float(r["high"]),
|
||||
"low": float(r["low"]),
|
||||
"close": float(r["close"]),
|
||||
"volume": float(r["volume"] or 0),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def _to_chart_candles(bars: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for b in bars:
|
||||
try:
|
||||
out.append(
|
||||
{
|
||||
"time": int(b["open_time_ms"] // 1000),
|
||||
"open": float(b["open"]),
|
||||
"high": float(b["high"]),
|
||||
"low": float(b["low"]),
|
||||
"close": float(b["close"]),
|
||||
"volume": float(b.get("volume") or 0),
|
||||
}
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _trim_display_bars(
|
||||
bars: list[dict[str, Any]],
|
||||
*,
|
||||
need: int,
|
||||
before_ms: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not bars:
|
||||
return []
|
||||
if before_ms is not None and int(before_ms) > 0:
|
||||
bms = int(before_ms)
|
||||
bars = [b for b in bars if int(b["open_time_ms"]) < bms]
|
||||
if len(bars) > need:
|
||||
bars = bars[-need:]
|
||||
return bars
|
||||
if len(bars) > need:
|
||||
bars = bars[-need:]
|
||||
return bars
|
||||
|
||||
|
||||
def resolve_chart_bars(
|
||||
exchange_key: str,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
remote_fetch: Callable[..., dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
force_refresh: bool = False,
|
||||
tail_refresh: bool = False,
|
||||
clear_db: bool = False,
|
||||
limit: int | None = None,
|
||||
before_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
分页读库:首屏 / 左拖 before_ms / 尾部 tail_refresh.
|
||||
各展示周期均直读交易所同步入库的同名 K 线.
|
||||
"""
|
||||
init_db(db_path)
|
||||
purged = purge_retention(db_path)
|
||||
cleared = 0
|
||||
|
||||
sym = (symbol or "").strip().upper()
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
display_tf = normalize_chart_timeframe(timeframe)
|
||||
if not sym or not ex_k:
|
||||
return {"ok": False, "msg": "缺少 exchange 或 symbol"}
|
||||
|
||||
storage_tf = display_tf
|
||||
is_history = before_ms is not None and int(before_ms) > 0
|
||||
need = int(
|
||||
limit
|
||||
or (chart_chunk_limit(display_tf) if is_history else chart_initial_limit(display_tf))
|
||||
)
|
||||
need = max(1, min(need, chart_memory_cap(display_tf)))
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
period_display = TIMEFRAME_MS[display_tf]
|
||||
period_storage = TIMEFRAME_MS[storage_tf]
|
||||
series_bar_count = (
|
||||
count_series_bars(ex_k, sym, storage_tf, db_path) if not is_history else 0
|
||||
)
|
||||
if tail_refresh and not is_history:
|
||||
min_seed = min(chart_initial_limit(display_tf) // 5, HUB_KLINE_MIN_BARS_BEFORE_TAIL)
|
||||
if series_bar_count < max(1, min_seed):
|
||||
tail_refresh = False
|
||||
else:
|
||||
need = min(need, 30)
|
||||
cutoff = history_cutoff_ms_for_storage(storage_tf, now_ms)
|
||||
|
||||
if clear_db and not is_history and not tail_refresh:
|
||||
cleared = clear_series_bars(ex_k, sym, storage_tf, db_path)
|
||||
|
||||
def load_display_rows() -> list[dict[str, Any]]:
|
||||
if is_history:
|
||||
rows = load_bars_before(ex_k, sym, storage_tf, int(before_ms), need, db_path)
|
||||
return _trim_display_bars(rows, need=need, before_ms=int(before_ms))
|
||||
return load_bars_latest(ex_k, sym, storage_tf, need, db_path)
|
||||
|
||||
db_rows: list[dict[str, Any]] = []
|
||||
if not force_refresh:
|
||||
db_rows = load_display_rows()
|
||||
if not is_history and db_rows:
|
||||
db_rows = normalize_contiguous_db_rows(
|
||||
db_rows,
|
||||
period_ms=period_display,
|
||||
exchange_key=ex_k,
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
last_closed = last_closed_bar_open_ms(display_tf, now_ms)
|
||||
newest_db = db_rows[-1]["open_time_ms"] if db_rows else None
|
||||
if is_history:
|
||||
newest_ok = True
|
||||
else:
|
||||
newest_ok = newest_db is not None and int(newest_db) >= int(last_closed) - period_display
|
||||
|
||||
need_fetch = force_refresh or (
|
||||
not is_history and (len(db_rows) < need or not newest_ok)
|
||||
)
|
||||
if is_history and len(db_rows) < need:
|
||||
need_fetch = True
|
||||
|
||||
tail_only = False
|
||||
if tail_refresh and not is_history and db_rows and not force_refresh and not need_fetch:
|
||||
need_fetch = True
|
||||
tail_only = True
|
||||
|
||||
fetched = 0
|
||||
price_tick: Optional[float] = None
|
||||
remote_err: Optional[str] = None
|
||||
|
||||
if need_fetch:
|
||||
if is_history:
|
||||
bms = int(before_ms)
|
||||
anchor = bms - period_display
|
||||
since = max(cutoff, anchor - period_storage * need)
|
||||
fetch_limit = min(need + 20, 1500)
|
||||
elif tail_only:
|
||||
anchor_ms = int(newest_db) if newest_db is not None else now_ms
|
||||
fetch_limit = _remote_fetch_limit(
|
||||
need=need, force_refresh=False, storage_tf=storage_tf, tail_only=True
|
||||
)
|
||||
since = _since_ms_for_span(
|
||||
now_ms=anchor_ms,
|
||||
period_ms=period_storage,
|
||||
span_bars=5,
|
||||
cutoff_ms=cutoff,
|
||||
)
|
||||
else:
|
||||
fetch_limit = _remote_fetch_limit(
|
||||
need=need,
|
||||
force_refresh=force_refresh,
|
||||
storage_tf=storage_tf,
|
||||
tail_only=False,
|
||||
)
|
||||
since = _since_ms_for_span(
|
||||
now_ms=now_ms,
|
||||
period_ms=period_storage,
|
||||
span_bars=fetch_limit,
|
||||
cutoff_ms=cutoff,
|
||||
)
|
||||
|
||||
remote = remote_fetch(
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
since_ms=since,
|
||||
limit=fetch_limit,
|
||||
)
|
||||
if remote.get("ok") and remote.get("bars"):
|
||||
fetched = upsert_bars(ex_k, sym, storage_tf, remote["bars"], db_path)
|
||||
price_tick = remote.get("price_tick")
|
||||
if price_tick is not None:
|
||||
save_symbol_price_tick(ex_k, sym, price_tick, db_path)
|
||||
db_rows = load_display_rows()
|
||||
if not is_history and db_rows:
|
||||
db_rows = normalize_contiguous_db_rows(
|
||||
db_rows,
|
||||
period_ms=period_display,
|
||||
exchange_key=ex_k,
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
db_path=db_path,
|
||||
)
|
||||
if not is_history and not tail_only and db_rows:
|
||||
newest_ms = int(db_rows[-1]["open_time_ms"])
|
||||
if newest_ms < int(last_closed) - period_display:
|
||||
gap_limit = min(
|
||||
500,
|
||||
int((now_ms - newest_ms) // period_storage) + 10,
|
||||
)
|
||||
if gap_limit > 1:
|
||||
gap_remote = remote_fetch(
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
since_ms=newest_ms,
|
||||
limit=gap_limit,
|
||||
)
|
||||
if gap_remote.get("ok") and gap_remote.get("bars"):
|
||||
fetched += upsert_bars(
|
||||
ex_k, sym, storage_tf, gap_remote["bars"], db_path
|
||||
)
|
||||
db_rows = load_display_rows()
|
||||
db_rows = normalize_contiguous_db_rows(
|
||||
db_rows,
|
||||
period_ms=period_display,
|
||||
exchange_key=ex_k,
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
db_path=db_path,
|
||||
)
|
||||
else:
|
||||
remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败"
|
||||
if not db_rows:
|
||||
if is_history:
|
||||
exhausted = True
|
||||
else:
|
||||
return {"ok": False, "msg": remote_err, "purged": purged}
|
||||
|
||||
exhausted = False
|
||||
if is_history:
|
||||
if not db_rows:
|
||||
exhausted = True
|
||||
elif len(db_rows) < need:
|
||||
oldest = int(db_rows[0]["open_time_ms"])
|
||||
if cutoff > 0 and oldest <= cutoff + period_storage:
|
||||
exhausted = True
|
||||
elif fetched == 0:
|
||||
exhausted = True
|
||||
|
||||
if price_tick is None:
|
||||
price_tick = load_symbol_price_tick(ex_k, sym, db_path)
|
||||
if price_tick is None and not is_history:
|
||||
try:
|
||||
tick_probe = remote_fetch(
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
since_ms=None,
|
||||
limit=3,
|
||||
)
|
||||
if tick_probe.get("ok"):
|
||||
price_tick = tick_probe.get("price_tick")
|
||||
if price_tick is not None:
|
||||
save_symbol_price_tick(ex_k, sym, price_tick, db_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not is_history and db_rows:
|
||||
db_rows = normalize_contiguous_db_rows(
|
||||
db_rows,
|
||||
period_ms=period_display,
|
||||
exchange_key=ex_k,
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
if not is_history and len(db_rows) < need:
|
||||
missing = need - len(db_rows)
|
||||
backfill_limit = min(missing + 60, HUB_KLINE_REMOTE_FETCH_CAP)
|
||||
if db_rows:
|
||||
oldest = int(db_rows[0]["open_time_ms"])
|
||||
backfill_since = _since_ms_for_span(
|
||||
now_ms=oldest,
|
||||
period_ms=period_storage,
|
||||
span_bars=backfill_limit,
|
||||
cutoff_ms=cutoff,
|
||||
)
|
||||
else:
|
||||
backfill_since = _since_ms_for_span(
|
||||
now_ms=now_ms,
|
||||
period_ms=period_storage,
|
||||
span_bars=backfill_limit,
|
||||
cutoff_ms=cutoff,
|
||||
)
|
||||
try:
|
||||
remote_back = remote_fetch(
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
since_ms=backfill_since,
|
||||
limit=backfill_limit,
|
||||
)
|
||||
if remote_back.get("ok") and remote_back.get("bars"):
|
||||
fetched += upsert_bars(ex_k, sym, storage_tf, remote_back["bars"], db_path)
|
||||
if remote_back.get("price_tick") is not None:
|
||||
price_tick = remote_back.get("price_tick")
|
||||
save_symbol_price_tick(ex_k, sym, price_tick, db_path)
|
||||
db_rows = load_display_rows()
|
||||
db_rows = normalize_contiguous_db_rows(
|
||||
db_rows,
|
||||
period_ms=period_display,
|
||||
exchange_key=ex_k,
|
||||
symbol=sym,
|
||||
timeframe=storage_tf,
|
||||
db_path=db_path,
|
||||
)
|
||||
elif not remote_err:
|
||||
remote_err = (
|
||||
remote_back.get("msg")
|
||||
or remote_back.get("error")
|
||||
or "实例补拉 K 线失败"
|
||||
)
|
||||
except Exception as e:
|
||||
if not remote_err:
|
||||
remote_err = str(e)
|
||||
|
||||
price_tick = normalize_price_tick(price_tick)
|
||||
if db_rows and price_tick is not None:
|
||||
round_ohlcv_bars_to_tick(db_rows, price_tick)
|
||||
|
||||
candles = _to_chart_candles(db_rows)
|
||||
if not is_history and not candles and not exhausted:
|
||||
return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged}
|
||||
|
||||
oldest_ms = int(db_rows[0]["open_time_ms"]) if db_rows else None
|
||||
newest_ms = int(db_rows[-1]["open_time_ms"]) if db_rows else None
|
||||
|
||||
from_cache = max(0, len(candles) - min(fetched, len(candles))) if fetched else len(candles)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": sym,
|
||||
"exchange_key": ex_k,
|
||||
"timeframe": display_tf,
|
||||
"storage_timeframe": storage_tf,
|
||||
"limit": need,
|
||||
"before_ms": int(before_ms) if is_history else None,
|
||||
"oldest_ms": oldest_ms,
|
||||
"newest_ms": newest_ms,
|
||||
"exhausted": exhausted,
|
||||
"source": "remote" if fetched else "db",
|
||||
"retention_policy": retention_policy_meta(),
|
||||
"candles": candles,
|
||||
"from_cache": from_cache,
|
||||
"fetched": fetched,
|
||||
"cleared": cleared,
|
||||
"purged": purged,
|
||||
"price_tick": price_tick,
|
||||
"stale": bool(remote_err),
|
||||
"stale_message": remote_err if remote_err else None,
|
||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
}
|
||||
|
||||
|
||||
def format_ohlcv_detail(bar: dict[str, Any] | None, tick: Optional[float]) -> dict[str, str]:
|
||||
if not bar:
|
||||
return {"open": "-", "high": "-", "low": "-", "close": "-", "volume": "-"}
|
||||
return {
|
||||
"open": format_price_by_tick(bar.get("open"), tick),
|
||||
"high": format_price_by_tick(bar.get("high"), tick),
|
||||
"low": format_price_by_tick(bar.get("low"), tick),
|
||||
"close": format_price_by_tick(bar.get("close"), tick),
|
||||
"volume": format_price_by_tick(bar.get("volume"), tick),
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"""中控宏观关键数据日历:手动录入 FOMC / CPI / 非农档发布时间,±1h 风控前置窗口."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import parse_wall_clock_ms
|
||||
|
||||
DISPLAY_TZ = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
|
||||
|
||||
MACRO_EVENT_TYPES = ("fomc", "cpi", "employment")
|
||||
|
||||
MACRO_EVENT_LABELS: dict[str, str] = {
|
||||
"fomc": "FOMC 联邦基金利率",
|
||||
"cpi": "美国 CPI 通胀",
|
||||
"employment": "就业与劳工数据",
|
||||
}
|
||||
|
||||
WINDOW_BEFORE_MS = int(os.getenv("HUB_MACRO_WINDOW_BEFORE_SEC", str(3600))) * 1000
|
||||
WINDOW_AFTER_MS = int(os.getenv("HUB_MACRO_WINDOW_AFTER_SEC", str(3600))) * 1000
|
||||
IMMINENT_BEFORE_MS = int(os.getenv("HUB_MACRO_IMMINENT_BEFORE_SEC", str(1800))) * 1000
|
||||
LIST_FUTURE_DAYS = int(os.getenv("HUB_MACRO_LIST_FUTURE_DAYS", "60"))
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
raw = (os.getenv("HUB_MACRO_CALENDAR_DB_PATH") or "").strip()
|
||||
if raw:
|
||||
return Path(raw)
|
||||
from lib.paths import hub_data_dir
|
||||
|
||||
return hub_data_dir() / "hub_macro_calendar.db"
|
||||
|
||||
|
||||
def _connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
path = db_path or default_db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), timeout=30, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db(db_path: Path | None = None) -> None:
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS macro_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_type TEXT NOT NULL,
|
||||
event_at_ms INTEGER NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_macro_events_at ON macro_events(event_at_ms)"
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def normalize_event_type(raw: str) -> str:
|
||||
key = (raw or "").strip().lower()
|
||||
if key not in MACRO_EVENT_TYPES:
|
||||
raise ValueError(f"事件类型须为: {', '.join(MACRO_EVENT_LABELS.values())}")
|
||||
return key
|
||||
|
||||
|
||||
def parse_event_at_ms(raw: Any) -> int:
|
||||
ms = parse_wall_clock_ms(raw, tz=DISPLAY_TZ)
|
||||
if ms is None:
|
||||
raise ValueError("发布时间格式错误,请使用 YYYY-MM-DD HH:MM 或 YYYY-MM-DDTHH:MM")
|
||||
return int(ms)
|
||||
|
||||
|
||||
def format_event_at(ms: int) -> str:
|
||||
dt = datetime.fromtimestamp(ms / 1000, tz=DISPLAY_TZ)
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
|
||||
ms = int(row["event_at_ms"])
|
||||
et = str(row["event_type"])
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"event_type": et,
|
||||
"event_type_label": MACRO_EVENT_LABELS.get(et, et),
|
||||
"event_at_ms": ms,
|
||||
"event_at": format_event_at(ms),
|
||||
"note": str(row["note"] or ""),
|
||||
"created_at_ms": int(row["created_at_ms"]),
|
||||
"updated_at_ms": int(row["updated_at_ms"]),
|
||||
}
|
||||
|
||||
|
||||
def _window_bounds(event_at_ms: int) -> tuple[int, int]:
|
||||
start = int(event_at_ms) - WINDOW_BEFORE_MS
|
||||
end = int(event_at_ms) + WINDOW_AFTER_MS
|
||||
return start, end
|
||||
|
||||
|
||||
def enrich_alert(row: dict[str, Any], now_ms: int | None = None) -> dict[str, Any] | None:
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
event_at_ms = int(row["event_at_ms"])
|
||||
window_start, window_end = _window_bounds(event_at_ms)
|
||||
if now < window_start or now > window_end:
|
||||
return None
|
||||
imminent = now >= (event_at_ms - IMMINENT_BEFORE_MS) and now <= window_end
|
||||
mins_to_event = max(0, int((event_at_ms - now) / 60000))
|
||||
mins_from_event = max(0, int((now - event_at_ms) / 60000))
|
||||
return {
|
||||
**row,
|
||||
"window_start_ms": window_start,
|
||||
"window_end_ms": window_end,
|
||||
"window_start": format_event_at(window_start),
|
||||
"window_end": format_event_at(window_end),
|
||||
"phase": "imminent" if imminent else "window",
|
||||
"phase_label": "即将发布" if imminent and now < event_at_ms else "高波动窗口",
|
||||
"minutes_to_event": mins_to_event if now < event_at_ms else 0,
|
||||
"minutes_from_event": mins_from_event if now >= event_at_ms else 0,
|
||||
}
|
||||
|
||||
|
||||
def list_events(
|
||||
*,
|
||||
now_ms: int | None = None,
|
||||
include_expired_hours: int = 24,
|
||||
db_path: Path | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
init_db(db_path)
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
horizon = now + LIST_FUTURE_DAYS * 86400 * 1000
|
||||
expired_cutoff = now - max(0, int(include_expired_hours)) * 3600 * 1000 - WINDOW_AFTER_MS
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM macro_events
|
||||
WHERE event_at_ms >= ? AND event_at_ms <= ?
|
||||
ORDER BY event_at_ms ASC, id ASC
|
||||
""",
|
||||
(expired_cutoff, horizon),
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_event(event_id: int, db_path: Path | None = None) -> dict[str, Any] | None:
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM macro_events WHERE id=?", (int(event_id),)).fetchone()
|
||||
return _row_to_dict(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _assert_no_duplicate(
|
||||
conn: sqlite3.Connection,
|
||||
event_type: str,
|
||||
event_at_ms: int,
|
||||
*,
|
||||
exclude_id: int | None = None,
|
||||
) -> None:
|
||||
if exclude_id is None:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM macro_events WHERE event_type=? AND event_at_ms=? LIMIT 1",
|
||||
(event_type, int(event_at_ms)),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id FROM macro_events
|
||||
WHERE event_type=? AND event_at_ms=? AND id<>?
|
||||
LIMIT 1
|
||||
""",
|
||||
(event_type, int(event_at_ms), int(exclude_id)),
|
||||
).fetchone()
|
||||
if row:
|
||||
raise ValueError("同类型,同发布时间的记录已存在")
|
||||
|
||||
|
||||
def create_event(
|
||||
event_type: str,
|
||||
event_at: Any,
|
||||
*,
|
||||
note: str = "",
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_db(db_path)
|
||||
et = normalize_event_type(event_type)
|
||||
event_at_ms = parse_event_at_ms(event_at)
|
||||
note_s = str(note or "").strip()[:500]
|
||||
now_ms = int(time.time() * 1000)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
_assert_no_duplicate(conn, et, event_at_ms)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO macro_events (event_type, event_at_ms, note, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(et, event_at_ms, note_s, now_ms, now_ms),
|
||||
)
|
||||
eid = int(cur.lastrowid)
|
||||
finally:
|
||||
conn.close()
|
||||
row = get_event(eid, db_path=db_path)
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def update_event(
|
||||
event_id: int,
|
||||
*,
|
||||
event_type: str | None = None,
|
||||
event_at: Any | None = None,
|
||||
note: str | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
init_db(db_path)
|
||||
existing = get_event(event_id, db_path=db_path)
|
||||
if not existing:
|
||||
return None
|
||||
et = normalize_event_type(event_type if event_type is not None else existing["event_type"])
|
||||
event_at_ms = (
|
||||
parse_event_at_ms(event_at) if event_at is not None else int(existing["event_at_ms"])
|
||||
)
|
||||
note_s = existing["note"] if note is None else str(note or "").strip()[:500]
|
||||
now_ms = int(time.time() * 1000)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
_assert_no_duplicate(conn, et, event_at_ms, exclude_id=int(event_id))
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE macro_events
|
||||
SET event_type=?, event_at_ms=?, note=?, updated_at_ms=?
|
||||
WHERE id=?
|
||||
""",
|
||||
(et, event_at_ms, note_s, now_ms, int(event_id)),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
return get_event(event_id, db_path=db_path)
|
||||
|
||||
|
||||
def delete_event(event_id: int, db_path: Path | None = None) -> bool:
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM macro_events WHERE id=?", (int(event_id),))
|
||||
return cur.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_active_alerts(
|
||||
now_ms: int | None = None,
|
||||
db_path: Path | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
lookback = now - WINDOW_BEFORE_MS - IMMINENT_BEFORE_MS
|
||||
lookahead = now + WINDOW_AFTER_MS
|
||||
init_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM macro_events
|
||||
WHERE event_at_ms >= ? AND event_at_ms <= ?
|
||||
ORDER BY event_at_ms ASC, id ASC
|
||||
""",
|
||||
(lookback, lookahead),
|
||||
).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
alerts: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item = enrich_alert(_row_to_dict(row), now_ms=now)
|
||||
if item:
|
||||
alerts.append(item)
|
||||
return alerts
|
||||
|
||||
|
||||
def build_banner_message(alert: dict[str, Any], *, has_positions: bool) -> str:
|
||||
label = alert.get("event_type_label") or alert.get("event_type") or "宏观数据"
|
||||
phase = alert.get("phase") or "window"
|
||||
if has_positions:
|
||||
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
|
||||
return (
|
||||
f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
|
||||
"注意仓位风险:勿加仓,检查止损/减仓"
|
||||
)
|
||||
return f"「{label}」高波动窗口(±1h),注意仓位风险:勿加仓,检查止损/减仓"
|
||||
if phase == "imminent" and int(alert.get("minutes_to_event") or 0) > 0:
|
||||
return (
|
||||
f"「{label}」即将发布(约 {alert['minutes_to_event']} 分钟),"
|
||||
"建议等待,避免新开仓"
|
||||
)
|
||||
return f"「{label}」高波动窗口(±1h),建议等待,避免新开仓"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""实例 USDT 永续合约信息(与实盘 ccxt 精度一致)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
|
||||
from lib.hub.hub_calculator_market_lib import (
|
||||
amount_decimals_from_exchange,
|
||||
normalize_base_symbol,
|
||||
price_decimals_from_exchange,
|
||||
resolve_usdt_perp_symbol,
|
||||
)
|
||||
from lib.hub.hub_ohlcv_lib import normalize_price_tick, price_tick_from_market
|
||||
|
||||
|
||||
def fetch_usdt_swap_market_info(
|
||||
*,
|
||||
base_or_symbol: str,
|
||||
normalize_symbol_input: Callable[[str], str],
|
||||
normalize_exchange_symbol: Callable[[str], str],
|
||||
ensure_markets_loaded: Callable[[], None],
|
||||
exchange: Any,
|
||||
exchange_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""供各实例 /api/hub/market 调用."""
|
||||
raw = str(base_or_symbol or "").strip()
|
||||
if not raw:
|
||||
return {"ok": False, "msg": "请输入币种,如 ETH"}
|
||||
|
||||
try:
|
||||
ensure_markets_loaded()
|
||||
except Exception as exc:
|
||||
return {"ok": False, "msg": f"加载市场失败: {exc}"}
|
||||
|
||||
base_u = normalize_base_symbol(raw)
|
||||
hub_sym = normalize_symbol_input(raw if base_u else raw)
|
||||
try:
|
||||
ex_sym = normalize_exchange_symbol(hub_sym)
|
||||
except Exception:
|
||||
ex_sym = hub_sym
|
||||
|
||||
sym, err = resolve_usdt_perp_symbol(exchange, base_u or hub_sym)
|
||||
if err and ex_sym:
|
||||
markets = getattr(exchange, "markets", None) or {}
|
||||
if ex_sym in markets:
|
||||
sym = ex_sym
|
||||
err = None
|
||||
if err or not sym:
|
||||
return {"ok": False, "msg": err or f"未找到 {base_u or raw}/USDT 永续合约"}
|
||||
|
||||
market = exchange.market(sym)
|
||||
try:
|
||||
contract_size = float(market.get("contractSize") or 1.0)
|
||||
except (TypeError, ValueError):
|
||||
contract_size = 1.0
|
||||
if contract_size <= 0:
|
||||
contract_size = 1.0
|
||||
|
||||
price_tick = normalize_price_tick(price_tick_from_market(exchange, sym))
|
||||
amt_dec = amount_decimals_from_exchange(exchange, sym)
|
||||
px_dec = price_decimals_from_exchange(exchange, sym, price_tick)
|
||||
min_amount = None
|
||||
try:
|
||||
min_amount = float((market.get("limits") or {}).get("amount", {}).get("min"))
|
||||
except (TypeError, ValueError):
|
||||
min_amount = None
|
||||
|
||||
base_out = (market.get("base") or base_u or "").upper() or base_u
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange": (exchange_id or "").strip().lower(),
|
||||
"base": base_out,
|
||||
"exchange_symbol": sym,
|
||||
"display_symbol": f"{base_out}/USDT" if base_out else sym,
|
||||
"contract_size": contract_size,
|
||||
"price_tick": price_tick,
|
||||
"price_decimals": px_dec,
|
||||
"amount_decimals": amt_dec,
|
||||
"min_amount": min_amount,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""监控区看板:三所当日统计聚合."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from lib.hub.hub_options_funds_lib import (
|
||||
options_float_pnl_usdt,
|
||||
options_open_position_count as count_options_positions,
|
||||
)
|
||||
from lib.hub.hub_position_metrics import is_option_like_position
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def position_unrealized_pnl(pos: dict[str, Any]) -> float:
|
||||
for key in ("unrealized_pnl", "unrealizedPnl", "upnl"):
|
||||
v = _coerce_float(pos.get(key))
|
||||
if v is not None:
|
||||
return v
|
||||
return 0.0
|
||||
|
||||
|
||||
def _open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not isinstance(agent, dict):
|
||||
return []
|
||||
positions = agent.get("positions")
|
||||
if not isinstance(positions, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if is_option_like_position(p):
|
||||
continue
|
||||
try:
|
||||
c = abs(float(p.get("contracts") or 0))
|
||||
except (TypeError, ValueError):
|
||||
c = 0.0
|
||||
if c > 1e-12:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _raw_open_positions(agent: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not isinstance(agent, dict):
|
||||
return []
|
||||
positions = agent.get("positions")
|
||||
if not isinstance(positions, list):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
try:
|
||||
c = abs(float(p.get("contracts") or 0))
|
||||
except (TypeError, ValueError):
|
||||
c = 0.0
|
||||
if c > 1e-12:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_monitor_board_totals(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
trading_day: str,
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
"""汇总监控 board 各行 → 左上统计卡数据."""
|
||||
open_count = 0
|
||||
closed_count = 0
|
||||
win_count = 0
|
||||
loss_count = 0
|
||||
win_pnl_u = 0.0
|
||||
loss_pnl_u = 0.0
|
||||
open_position_count = 0
|
||||
options_open_position_count = 0
|
||||
float_pnl_u = 0.0
|
||||
options_float_pnl_u = 0.0
|
||||
|
||||
for row in rows or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
day_stats = row.get("day_stats") if isinstance(row.get("day_stats"), dict) else {}
|
||||
if day_stats.get("ok"):
|
||||
open_count += int(day_stats.get("opens_today") or 0)
|
||||
st = day_stats.get("trade_stats") if isinstance(day_stats.get("trade_stats"), dict) else {}
|
||||
closed_count += int(st.get("closed_count") or 0)
|
||||
win_count += int(st.get("win_count") or 0)
|
||||
loss_count += int(st.get("loss_count") or 0)
|
||||
win_pnl_u += float(st.get("win_pnl_u") or 0)
|
||||
loss_pnl_u += float(st.get("loss_pnl_u") or 0)
|
||||
|
||||
ag = row.get("agent") if isinstance(row.get("agent"), dict) else {}
|
||||
open_pos = _open_positions(ag)
|
||||
open_position_count += len(open_pos)
|
||||
raw_pos = _raw_open_positions(ag)
|
||||
contaminated = any(is_option_like_position(p) for p in raw_pos)
|
||||
agent_upnl = _coerce_float(ag.get("total_unrealized_pnl"))
|
||||
# 子代理若把期权混进永续合计,改按过滤后腿求和;期权浮盈由下方 options 段计入
|
||||
if agent_upnl is not None and not contaminated:
|
||||
float_pnl_u += agent_upnl
|
||||
else:
|
||||
float_pnl_u += sum(position_unrealized_pnl(p) for p in open_pos)
|
||||
|
||||
opt_snap = row.get("options") if "options" in (row.get("capabilities") or []) else None
|
||||
opt_count = count_options_positions(opt_snap)
|
||||
options_open_position_count += opt_count
|
||||
open_position_count += opt_count
|
||||
opt_upl = options_float_pnl_usdt(opt_snap)
|
||||
if opt_upl is not None:
|
||||
options_float_pnl_u += opt_upl
|
||||
float_pnl_u += opt_upl
|
||||
|
||||
return {
|
||||
"trading_day": trading_day,
|
||||
"reset_hour": int(reset_hour),
|
||||
"open_count": open_count,
|
||||
"closed_count": closed_count,
|
||||
"win_count": win_count,
|
||||
"loss_count": loss_count,
|
||||
"win_pnl_u": round(win_pnl_u, 4),
|
||||
"loss_pnl_u": round(loss_pnl_u, 4),
|
||||
"realized_pnl_u": round(win_pnl_u + loss_pnl_u, 4),
|
||||
"open_position_count": open_position_count,
|
||||
"options_open_position_count": options_open_position_count,
|
||||
"float_pnl_u": round(float_pnl_u, 4),
|
||||
"options_float_pnl_u": round(options_float_pnl_u, 4),
|
||||
}
|
||||
@@ -0,0 +1,692 @@
|
||||
"""中控行情区:各实例 ccxt OHLCV 拉取(hub_bridge /api/hub/ohlcv 共用)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
CHART_TIMEFRAMES = frozenset(
|
||||
{
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"2h",
|
||||
"4h",
|
||||
"1d",
|
||||
"1w",
|
||||
}
|
||||
)
|
||||
CHART_TIMEFRAME_ORDER = (
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"2h",
|
||||
"4h",
|
||||
"1d",
|
||||
"1w",
|
||||
)
|
||||
DAILY_PLUS_TIMEFRAMES = frozenset({"1d", "1w"})
|
||||
|
||||
# 入库 / 同步真源(各周期直拉交易所,不做本地聚合)
|
||||
STORED_TIMEFRAMES = frozenset(CHART_TIMEFRAMES)
|
||||
PERMANENT_STORED_TIMEFRAMES = frozenset({"1d", "1w"})
|
||||
YEAR_ROLLING_STORED = frozenset({"5m", "15m", "1h", "2h", "4h"})
|
||||
|
||||
# 行情区不做展示周期聚合;保留空映射供兼容读取
|
||||
CHART_DISPLAY_AGGREGATE_FROM: dict[str, str] = {}
|
||||
|
||||
SMALL_DISPLAY_TFS = frozenset({"1m", "5m", "15m"})
|
||||
MID_DISPLAY_TFS = frozenset({"1h", "2h", "4h"})
|
||||
|
||||
HUB_KLINE_1M_MAX_BARS = max(1000, int(os.getenv("HUB_KLINE_1M_MAX_BARS", "10000")))
|
||||
HUB_KLINE_5M_1H_RETENTION_DAYS = max(30, int(os.getenv("HUB_KLINE_5M_1H_RETENTION_DAYS", "365")))
|
||||
HUB_KLINE_SEED_BARS = max(100, int(os.getenv("HUB_KLINE_SEED_BARS", "500")))
|
||||
|
||||
# 交易所无原生周期时的远程拉取 fallback(行情区当前无映射)
|
||||
OHLCV_AGGREGATE_FROM: dict[str, str] = {}
|
||||
|
||||
TIMEFRAME_MS: dict[str, int] = {
|
||||
"1m": 60_000,
|
||||
"5m": 5 * 60_000,
|
||||
"15m": 15 * 60_000,
|
||||
"1h": 60 * 60_000,
|
||||
"2h": 2 * 60 * 60_000,
|
||||
"4h": 4 * 60 * 60_000,
|
||||
"12h": 12 * 60 * 60_000,
|
||||
"1d": 24 * 60 * 60_000,
|
||||
"1w": 7 * 24 * 60 * 60_000,
|
||||
}
|
||||
|
||||
|
||||
def normalize_chart_timeframe(raw: str | None, default: str = "5m") -> str:
|
||||
tf = (raw or default).strip().lower()
|
||||
return tf if tf in CHART_TIMEFRAMES else default
|
||||
|
||||
|
||||
def normalize_perpetual_symbol(symbol: str) -> str:
|
||||
"""BTC/USDT → BTC/USDT:USDT(与三所 ccxt swap 行情一致)."""
|
||||
sym = (symbol or "").strip().upper()
|
||||
if not sym:
|
||||
return ""
|
||||
if ":" in sym:
|
||||
return sym
|
||||
if "/" in sym:
|
||||
base, quote = sym.split("/", 1)
|
||||
quote_clean = quote.split(":")[0]
|
||||
return f"{base}/{quote_clean}:{quote_clean}"
|
||||
return sym
|
||||
|
||||
|
||||
def sync_timeframe_for_display(timeframe: str) -> str:
|
||||
"""展示周期对应的入库 / 同步周期."""
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
return CHART_DISPLAY_AGGREGATE_FROM.get(tf, tf)
|
||||
|
||||
|
||||
def aggregation_source_for_display(timeframe: str) -> str | None:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
return CHART_DISPLAY_AGGREGATE_FROM.get(tf)
|
||||
|
||||
|
||||
def aggregate_ratio(display_tf: str, source_tf: str) -> int:
|
||||
d = normalize_chart_timeframe(display_tf)
|
||||
s = normalize_chart_timeframe(source_tf)
|
||||
return max(1, int(TIMEFRAME_MS[d] // TIMEFRAME_MS[s]))
|
||||
|
||||
|
||||
def chart_initial_limit(timeframe: str) -> int:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
if tf in SMALL_DISPLAY_TFS:
|
||||
return 2000
|
||||
if tf in MID_DISPLAY_TFS:
|
||||
return 1000
|
||||
if tf in DAILY_PLUS_TIMEFRAMES:
|
||||
return 500
|
||||
return 500
|
||||
|
||||
|
||||
def chart_chunk_limit(timeframe: str) -> int:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
if tf in SMALL_DISPLAY_TFS:
|
||||
return 500
|
||||
if tf == "1w":
|
||||
return 150
|
||||
if tf in MID_DISPLAY_TFS:
|
||||
return 300
|
||||
return 200
|
||||
|
||||
|
||||
def chart_memory_cap(timeframe: str) -> int:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
if tf in SMALL_DISPLAY_TFS:
|
||||
return 5000
|
||||
if tf == "1w":
|
||||
return 500
|
||||
return 1000
|
||||
|
||||
|
||||
def bar_limit_for_timeframe(timeframe: str) -> int:
|
||||
return chart_memory_cap(timeframe)
|
||||
|
||||
|
||||
def storage_retention_days(storage_tf: str) -> int | None:
|
||||
"""None 表示不按天截断(1m 按根数;1d/1w 永久)."""
|
||||
tf = normalize_chart_timeframe(storage_tf)
|
||||
if tf in YEAR_ROLLING_STORED:
|
||||
return HUB_KLINE_5M_1H_RETENTION_DAYS
|
||||
return None
|
||||
|
||||
|
||||
def history_cutoff_ms_for_storage(storage_tf: str, now_ms: int | None = None) -> int:
|
||||
days = storage_retention_days(storage_tf)
|
||||
if days is None:
|
||||
return 0
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
return max(0, now - int(days) * 86400000)
|
||||
|
||||
|
||||
def seed_bar_target(storage_tf: str) -> int:
|
||||
tf = normalize_chart_timeframe(storage_tf)
|
||||
if tf == "1m":
|
||||
return HUB_KLINE_1M_MAX_BARS
|
||||
if tf in YEAR_ROLLING_STORED:
|
||||
period = TIMEFRAME_MS[tf]
|
||||
return min(
|
||||
int(86400000 * HUB_KLINE_5M_1H_RETENTION_DAYS / period) + 20,
|
||||
150000,
|
||||
)
|
||||
return HUB_KLINE_SEED_BARS
|
||||
|
||||
|
||||
def retention_policy_meta() -> dict[str, Any]:
|
||||
year = {"mode": "days", "days": HUB_KLINE_5M_1H_RETENTION_DAYS}
|
||||
return {
|
||||
"1m": {"mode": "bars", "max_bars": HUB_KLINE_1M_MAX_BARS},
|
||||
"5m": dict(year),
|
||||
"15m": dict(year),
|
||||
"1h": dict(year),
|
||||
"2h": dict(year),
|
||||
"4h": dict(year),
|
||||
"1d": {"mode": "permanent"},
|
||||
"1w": {"mode": "permanent"},
|
||||
"aggregate_from": {},
|
||||
}
|
||||
|
||||
|
||||
def last_closed_bar_open_ms(timeframe: str, now_ms: int | None = None) -> int:
|
||||
"""上一根已收盘 K 的 open_time(毫秒 UTC)."""
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
period = TIMEFRAME_MS[tf]
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
current_open = (now // period) * period
|
||||
return int(current_open - period)
|
||||
|
||||
|
||||
def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int:
|
||||
"""本地库清理/读库窗口:不超过 retention_days."""
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
|
||||
retention_cutoff = now - max(1, int(retention_days)) * 86400000
|
||||
want = now - max(1, int(need)) * period
|
||||
return max(retention_cutoff, want)
|
||||
|
||||
|
||||
def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -> int:
|
||||
"""行情展示拉取起点:按 need 根回看(日线 500 / 日内 1000),不受 DB 保留天数限制."""
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
|
||||
return max(0, now - max(1, int(need)) * period)
|
||||
|
||||
|
||||
def _positive_float(value: Any) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if v > 0 else None
|
||||
|
||||
|
||||
def _price_tick_from_market_info(info: dict) -> Optional[float]:
|
||||
"""从 market.info 解析 tick(含币安 PRICE_FILTER.filters)."""
|
||||
for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"):
|
||||
v = _positive_float(info.get(key))
|
||||
if v is not None:
|
||||
return v
|
||||
|
||||
for key in ("pricePrecision", "price_precision"):
|
||||
raw = info.get(key)
|
||||
if raw in (None, ""):
|
||||
continue
|
||||
try:
|
||||
p = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
|
||||
return 10 ** (-int(p))
|
||||
if 0 < p < 1:
|
||||
return p
|
||||
|
||||
filters = info.get("filters")
|
||||
if isinstance(filters, list):
|
||||
for f in filters:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
if str(f.get("filterType") or "").upper() != "PRICE_FILTER":
|
||||
continue
|
||||
v = _positive_float(f.get("tickSize"))
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def round_price_to_tick(value: Any, tick: Optional[float]) -> Optional[float]:
|
||||
"""按交易所 tick 对齐价格(K 线/标记线与坐标轴一致)."""
|
||||
t = normalize_price_tick(tick)
|
||||
if t is None:
|
||||
return None
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
n = round(v / t) * t
|
||||
d = _decimals_from_tick(t)
|
||||
return float(f"{n:.{d}f}")
|
||||
|
||||
|
||||
def round_ohlcv_bars_to_tick(bars: list[dict[str, Any]], tick: Optional[float]) -> None:
|
||||
t = normalize_price_tick(tick)
|
||||
if t is None:
|
||||
return
|
||||
for b in bars:
|
||||
for key in ("open", "high", "low", "close"):
|
||||
if key in b:
|
||||
rounded = round_price_to_tick(b.get(key), t)
|
||||
if rounded is not None:
|
||||
b[key] = rounded
|
||||
|
||||
|
||||
def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
|
||||
"""最小价格变动单位(与交易所 tick / price_to_precision 一致)."""
|
||||
try:
|
||||
if not getattr(exchange, "markets", None):
|
||||
exchange.load_markets()
|
||||
market = exchange.market(exchange_symbol)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
info = market.get("info") or {}
|
||||
if isinstance(info, dict):
|
||||
tick = _price_tick_from_market_info(info)
|
||||
if tick is not None:
|
||||
return tick
|
||||
|
||||
limits = market.get("limits") or {}
|
||||
price_limits = limits.get("price") or {}
|
||||
if price_limits.get("min") not in (None, ""):
|
||||
try:
|
||||
v = float(price_limits["min"])
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
sample = exchange.price_to_precision(exchange_symbol, 12345.678901234)
|
||||
s = str(sample).strip()
|
||||
if "." in s:
|
||||
frac = s.split(".", 1)[1]
|
||||
if frac:
|
||||
return 10 ** (-len(frac))
|
||||
return 1.0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prec = (market.get("precision") or {}).get("price")
|
||||
if prec is not None:
|
||||
try:
|
||||
p = float(prec)
|
||||
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
|
||||
return 10 ** (-int(p))
|
||||
if 0 < p < 1:
|
||||
return p
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def normalize_price_tick(tick: Optional[float]) -> Optional[float]:
|
||||
"""将 tick 对齐为 10^-n,避免浮点噪声导致前端 lightweight-charts unexpected base."""
|
||||
if tick is None:
|
||||
return None
|
||||
try:
|
||||
t = float(tick)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if t <= 0:
|
||||
return None
|
||||
if t >= 1:
|
||||
return t
|
||||
try:
|
||||
exp = int(round(-math.log10(t)))
|
||||
except (ValueError, OverflowError):
|
||||
return None
|
||||
exp = max(0, min(12, exp))
|
||||
return 10 ** (-exp)
|
||||
|
||||
|
||||
def _decimals_from_tick(tick: float) -> int:
|
||||
if tick >= 1:
|
||||
return 0
|
||||
s = f"{tick:.12f}".rstrip("0")
|
||||
if "." in s:
|
||||
frac = s.split(".", 1)[1]
|
||||
if frac:
|
||||
return min(12, len(frac))
|
||||
return max(0, min(12, int(round(-math.log10(tick)))))
|
||||
|
||||
|
||||
def format_price_by_tick(value: Any, tick: Optional[float]) -> str:
|
||||
if value in (None, ""):
|
||||
return "-"
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
if v == 0:
|
||||
return "0"
|
||||
if tick and tick > 0:
|
||||
return f"{v:.{_decimals_from_tick(float(tick))}f}"
|
||||
av = abs(v)
|
||||
if av >= 10000:
|
||||
d = 2
|
||||
elif av >= 100:
|
||||
d = 3
|
||||
elif av >= 1:
|
||||
d = 4
|
||||
elif av >= 0.01:
|
||||
d = 6
|
||||
else:
|
||||
d = 8
|
||||
text = f"{v:.{d}f}"
|
||||
return text.rstrip("0").rstrip(".") if "." in text else text
|
||||
|
||||
|
||||
def exchange_supports_timeframe(exchange, timeframe: str) -> bool:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
tfs = getattr(exchange, "timeframes", None) or {}
|
||||
if not tfs:
|
||||
return True
|
||||
return tf in tfs
|
||||
|
||||
|
||||
def _median_bar_step_ms(bars: list[dict[str, Any]]) -> Optional[int]:
|
||||
if len(bars) < 2:
|
||||
return None
|
||||
steps: list[int] = []
|
||||
for i in range(1, min(len(bars), 64)):
|
||||
step = int(bars[i]["open_time_ms"]) - int(bars[i - 1]["open_time_ms"])
|
||||
if step > 0:
|
||||
steps.append(step)
|
||||
if not steps:
|
||||
return None
|
||||
steps.sort()
|
||||
return steps[len(steps) // 2]
|
||||
|
||||
|
||||
def bars_spacing_matches_timeframe(
|
||||
bars: list[dict[str, Any]], timeframe: str, *, tolerance: float = 0.08
|
||||
) -> bool:
|
||||
if len(bars) < 2:
|
||||
return True
|
||||
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
|
||||
step = _median_bar_step_ms(bars)
|
||||
if step is None:
|
||||
return False
|
||||
return abs(step - period) <= period * tolerance
|
||||
|
||||
|
||||
def align_bar_open_ms(open_time_ms: int, period_ms: int) -> int:
|
||||
return (int(open_time_ms) // period_ms) * period_ms
|
||||
|
||||
|
||||
def snap_to_bar_grid(ts_ms: int, origin_ms: int, step_ms: int) -> int:
|
||||
step = max(1, int(step_ms))
|
||||
origin = int(origin_ms)
|
||||
if ts_ms <= origin:
|
||||
return origin
|
||||
idx = (int(ts_ms) - origin + step - 1) // step
|
||||
return origin + idx * step
|
||||
|
||||
|
||||
def fill_missing_ohlcv_bars(
|
||||
bars: list[dict[str, Any]],
|
||||
period_ms: int,
|
||||
start_ms: int | None = None,
|
||||
end_ms: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""细周期缺口用上一根收盘价填平,保证聚合后 K 线时间轴连续."""
|
||||
by_ts: dict[int, dict[str, Any]] = {}
|
||||
for b in bars or []:
|
||||
try:
|
||||
by_ts[int(b["open_time_ms"])] = b
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
if not by_ts:
|
||||
return []
|
||||
keys = sorted(by_ts.keys())
|
||||
step_ms = max(1, int(period_ms))
|
||||
origin = keys[0]
|
||||
aligned_start = snap_to_bar_grid(
|
||||
int(start_ms if start_ms is not None else keys[0]), origin, step_ms
|
||||
)
|
||||
aligned_end = max(
|
||||
int(end_ms if end_ms is not None else keys[-1]),
|
||||
keys[-1],
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
last: dict[str, Any] | None = None
|
||||
for ts_key in keys:
|
||||
if ts_key <= aligned_start:
|
||||
last = by_ts[ts_key]
|
||||
ts = aligned_start
|
||||
while ts <= aligned_end:
|
||||
cur = by_ts.get(ts)
|
||||
if cur is not None:
|
||||
last = cur
|
||||
out.append(cur)
|
||||
elif last is not None:
|
||||
c = float(last["close"])
|
||||
out.append(
|
||||
{
|
||||
"open_time_ms": ts,
|
||||
"open": c,
|
||||
"high": c,
|
||||
"low": c,
|
||||
"close": c,
|
||||
"volume": 0.0,
|
||||
"filled": True,
|
||||
}
|
||||
)
|
||||
ts += step_ms
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_ohlcv_bars(
|
||||
bars: list[dict[str, Any]], target_timeframe: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""将细周期 OHLCV 聚合为目标周期(UTC 对齐 bucket)."""
|
||||
tf = normalize_chart_timeframe(target_timeframe)
|
||||
period = TIMEFRAME_MS[tf]
|
||||
buckets: dict[int, dict[str, Any]] = {}
|
||||
for b in bars or []:
|
||||
try:
|
||||
key = align_bar_open_ms(int(b["open_time_ms"]), period)
|
||||
o = float(b["open"])
|
||||
h = float(b["high"])
|
||||
l = float(b["low"])
|
||||
c = float(b["close"])
|
||||
v = float(b.get("volume") or 0)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
cur = buckets.get(key)
|
||||
if cur is None:
|
||||
buckets[key] = {
|
||||
"open_time_ms": key,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": v,
|
||||
}
|
||||
continue
|
||||
cur["high"] = max(float(cur["high"]), h)
|
||||
cur["low"] = min(float(cur["low"]), l)
|
||||
cur["close"] = c
|
||||
cur["volume"] = float(cur.get("volume") or 0) + v
|
||||
return [buckets[k] for k in sorted(buckets.keys())]
|
||||
|
||||
|
||||
def _next_since_from_batch(batch: list, period_ms: int) -> int:
|
||||
last_ts = int(batch[-1][0])
|
||||
if len(batch) >= 2:
|
||||
step = int(batch[-1][0]) - int(batch[-2][0])
|
||||
if step > 0:
|
||||
return last_ts + step
|
||||
return last_ts + period_ms
|
||||
|
||||
|
||||
def _paginate_fetch_ohlcv(
|
||||
exchange,
|
||||
ex_sym: str,
|
||||
timeframe: str,
|
||||
*,
|
||||
want: int,
|
||||
since_ms: int | None,
|
||||
period_ms: int,
|
||||
chunk_max: int = 300,
|
||||
) -> list[dict[str, Any]]:
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
collected: list = []
|
||||
if since_ms is not None and int(since_ms) > 0:
|
||||
since = int(since_ms)
|
||||
else:
|
||||
since = max(0, int(time.time() * 1000) - want * period_ms)
|
||||
|
||||
now_ms = int(time.time() * 1000)
|
||||
guard = 0
|
||||
prev_since = None
|
||||
while len(collected) < want and guard < 80:
|
||||
guard += 1
|
||||
if since >= now_ms:
|
||||
break
|
||||
req_limit = min(chunk_max, want - len(collected))
|
||||
try:
|
||||
batch = exchange.fetch_ohlcv(
|
||||
ex_sym, timeframe=tf, since=since, limit=req_limit
|
||||
)
|
||||
except Exception as e:
|
||||
err = str(e).lower()
|
||||
if collected and (
|
||||
"from" in err
|
||||
and "to" in err
|
||||
or "invalid request parameter" in err
|
||||
):
|
||||
break
|
||||
raise
|
||||
if not batch:
|
||||
break
|
||||
collected.extend(batch)
|
||||
next_since = _next_since_from_batch(batch, period_ms)
|
||||
if next_since >= now_ms:
|
||||
break
|
||||
if prev_since is not None and next_since <= prev_since:
|
||||
break
|
||||
prev_since = since
|
||||
since = next_since
|
||||
|
||||
bars = _bars_to_dicts(collected)
|
||||
uniq: dict[int, dict[str, Any]] = {}
|
||||
for b in bars:
|
||||
uniq[int(b["open_time_ms"])] = b
|
||||
merged = [uniq[k] for k in sorted(uniq.keys())]
|
||||
if len(merged) > want:
|
||||
merged = merged[-want:]
|
||||
return merged
|
||||
|
||||
|
||||
def _bars_to_dicts(ohlcv: list) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for bar in ohlcv or []:
|
||||
if not bar or len(bar) < 6:
|
||||
continue
|
||||
try:
|
||||
out.append(
|
||||
{
|
||||
"open_time_ms": int(bar[0]),
|
||||
"open": float(bar[1]),
|
||||
"high": float(bar[2]),
|
||||
"low": float(bar[3]),
|
||||
"close": float(bar[4]),
|
||||
"volume": float(bar[5]),
|
||||
}
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def fetch_ohlcv_for_hub(
|
||||
*,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
since_ms: int | None = None,
|
||||
limit: int = 500,
|
||||
normalize_symbol_input: Callable[[Any], str],
|
||||
normalize_exchange_symbol: Callable[[str], str],
|
||||
ensure_markets_loaded: Callable[[], None],
|
||||
exchange,
|
||||
friendly_error: Callable[[Exception], str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""从 ccxt 拉 OHLCV,供 hub_bridge /api/hub/ohlcv 返回."""
|
||||
tf = normalize_chart_timeframe(timeframe)
|
||||
sym = normalize_symbol_input(symbol)
|
||||
if not sym:
|
||||
return {"ok": False, "msg": "symbol 不能为空"}
|
||||
try:
|
||||
ensure_markets_loaded()
|
||||
ex_sym = normalize_exchange_symbol(sym)
|
||||
want = max(1, min(int(limit or bar_limit_for_timeframe(tf)), 1500))
|
||||
period = TIMEFRAME_MS[tf]
|
||||
merged: list[dict[str, Any]] = []
|
||||
src_tf = OHLCV_AGGREGATE_FROM.get(tf)
|
||||
|
||||
if exchange_supports_timeframe(exchange, tf):
|
||||
candidate = _paginate_fetch_ohlcv(
|
||||
exchange,
|
||||
ex_sym,
|
||||
tf,
|
||||
want=want,
|
||||
since_ms=since_ms,
|
||||
period_ms=period,
|
||||
)
|
||||
if candidate and bars_spacing_matches_timeframe(candidate, tf):
|
||||
merged = candidate
|
||||
|
||||
if (
|
||||
not merged
|
||||
and src_tf
|
||||
and exchange_supports_timeframe(exchange, src_tf)
|
||||
):
|
||||
src_period = TIMEFRAME_MS[normalize_chart_timeframe(src_tf)]
|
||||
ratio = max(1, int(math.ceil(period / src_period)))
|
||||
src_want = min(1500, want * ratio + ratio * 4)
|
||||
src_bars = _paginate_fetch_ohlcv(
|
||||
exchange,
|
||||
ex_sym,
|
||||
src_tf,
|
||||
want=src_want,
|
||||
since_ms=since_ms,
|
||||
period_ms=src_period,
|
||||
)
|
||||
if not src_bars or not bars_spacing_matches_timeframe(src_bars, src_tf):
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": f"无法获取 {tf} K 线(细周期 {src_tf} 数据异常)",
|
||||
}
|
||||
merged = aggregate_ohlcv_bars(src_bars, tf)
|
||||
if len(merged) > want:
|
||||
merged = merged[-want:]
|
||||
|
||||
if not merged:
|
||||
try:
|
||||
tail = exchange.fetch_ohlcv(
|
||||
ex_sym, timeframe=tf, limit=min(want, 300)
|
||||
)
|
||||
merged = _bars_to_dicts(tail or [])
|
||||
if len(merged) > want:
|
||||
merged = merged[-want:]
|
||||
except Exception:
|
||||
pass
|
||||
if not merged:
|
||||
return {"ok": False, "msg": "交易所未返回 K 线"}
|
||||
|
||||
tick = normalize_price_tick(price_tick_from_market(exchange, ex_sym))
|
||||
round_ohlcv_bars_to_tick(merged, tick)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"symbol": sym,
|
||||
"exchange_symbol": ex_sym,
|
||||
"timeframe": tf,
|
||||
"price_tick": tick,
|
||||
"bars": merged,
|
||||
}
|
||||
except Exception as e:
|
||||
msg = friendly_error(e) if friendly_error else str(e)
|
||||
return {"ok": False, "msg": f"K线加载失败:{msg}"}
|
||||
@@ -0,0 +1,599 @@
|
||||
"""中控期权档案:同步 OKX options_review_trades 到 hub_symbol_archive.db."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.hub.hub_symbol_archive_lib import (
|
||||
TRADING_DAY_RESET_HOUR,
|
||||
_connect,
|
||||
default_db_path,
|
||||
init_db as init_perp_archive_db,
|
||||
ms_to_trading_day,
|
||||
parse_wall_clock_ms,
|
||||
resolve_period_bounds,
|
||||
trading_day_bounds_ms,
|
||||
)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def init_options_archive_db(db_path: Path | None = None) -> None:
|
||||
"""确保期权缓存表存在(与永续共用同一 SQLite)."""
|
||||
init_perp_archive_db(db_path)
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS archive_options_trade_cache (
|
||||
exchange_key TEXT NOT NULL,
|
||||
history_key TEXT NOT NULL,
|
||||
source_type TEXT,
|
||||
underlying TEXT,
|
||||
opened_at TEXT,
|
||||
closed_at TEXT,
|
||||
opened_at_ms INTEGER,
|
||||
closed_at_ms INTEGER,
|
||||
hold_seconds INTEGER,
|
||||
realized_pnl_total REAL,
|
||||
status_raw TEXT,
|
||||
pos_id TEXT,
|
||||
inst_id TEXT,
|
||||
opt_type TEXT,
|
||||
strike REAL,
|
||||
exp_time TEXT,
|
||||
sheets INTEGER,
|
||||
open_avg REAL,
|
||||
close_avg REAL,
|
||||
premium_paid REAL,
|
||||
realized_pnl REAL,
|
||||
hedge_plan_id INTEGER,
|
||||
plan_close_reason TEXT,
|
||||
realized_pnl_perp REAL,
|
||||
realized_pnl_options REAL,
|
||||
premium_total REAL,
|
||||
direction TEXT,
|
||||
tp REAL,
|
||||
sl REAL,
|
||||
target_price REAL,
|
||||
target_price_up REAL,
|
||||
target_price_down REAL,
|
||||
legs_json TEXT,
|
||||
linked_hedge_plan_id INTEGER,
|
||||
excluded_as_hedge_leg INTEGER DEFAULT 0,
|
||||
strategy_tag TEXT,
|
||||
result_tag TEXT,
|
||||
reviewed INTEGER DEFAULT 0,
|
||||
source_label TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
synced_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (exchange_key, history_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_archive_options_closed
|
||||
ON archive_options_trade_cache (exchange_key, closed_at_ms)
|
||||
"""
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purge_stale_options_trades_cache(
|
||||
exchange_key: str,
|
||||
active_history_keys: list[str],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> int:
|
||||
init_options_archive_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
return 0
|
||||
active = {str(k).strip() for k in (active_history_keys or []) if str(k).strip()}
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT history_key FROM archive_options_trade_cache WHERE exchange_key=?",
|
||||
(ex_k,),
|
||||
).fetchall()
|
||||
stale = [r["history_key"] for r in rows if r["history_key"] not in active]
|
||||
removed = 0
|
||||
for hk in stale:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM archive_options_trade_cache WHERE exchange_key=? AND history_key=?",
|
||||
(ex_k, hk),
|
||||
)
|
||||
removed += int(cur.rowcount or 0)
|
||||
return removed
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _optional_float(raw: Any) -> float | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _optional_int(raw: Any) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def upsert_options_trades_cache(
|
||||
exchange_key: str,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
prune_missing: bool = True,
|
||||
) -> dict[str, int]:
|
||||
init_options_archive_db(db_path)
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
return {"upserted": 0, "removed": 0}
|
||||
now = _now_ms()
|
||||
n = 0
|
||||
active_keys: list[str] = []
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
for t in trades or []:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
hk = str(t.get("history_key") or "").strip()
|
||||
if not hk:
|
||||
continue
|
||||
if int(t.get("excluded_as_hedge_leg") or 0):
|
||||
continue
|
||||
active_keys.append(hk)
|
||||
opened_at = t.get("opened_at")
|
||||
closed_at = t.get("closed_at")
|
||||
opened_ms = t.get("opened_at_ms") or parse_wall_clock_ms(opened_at)
|
||||
closed_ms = t.get("closed_at_ms") or parse_wall_clock_ms(closed_at)
|
||||
entry = t.get("entry") if isinstance(t.get("entry"), dict) else {}
|
||||
strategy_tag = t.get("strategy_tag") or (entry or {}).get("strategy_tag")
|
||||
result_tag = t.get("result_tag") or (entry or {}).get("result_tag")
|
||||
reviewed = 1 if t.get("reviewed") or entry else 0
|
||||
row = dict(t)
|
||||
row["exchange_key"] = ex_k
|
||||
payload = json.dumps(row, ensure_ascii=False, default=str)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO archive_options_trade_cache (
|
||||
exchange_key, history_key, source_type, underlying,
|
||||
opened_at, closed_at, opened_at_ms, closed_at_ms, hold_seconds,
|
||||
realized_pnl_total, status_raw,
|
||||
pos_id, inst_id, opt_type, strike, exp_time, sheets,
|
||||
open_avg, close_avg, premium_paid, realized_pnl,
|
||||
hedge_plan_id, plan_close_reason, realized_pnl_perp, realized_pnl_options,
|
||||
premium_total, direction, tp, sl, target_price, target_price_up, target_price_down,
|
||||
legs_json, linked_hedge_plan_id, excluded_as_hedge_leg,
|
||||
strategy_tag, result_tag, reviewed, source_label,
|
||||
payload_json, synced_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(exchange_key, history_key) DO UPDATE SET
|
||||
source_type=excluded.source_type,
|
||||
underlying=excluded.underlying,
|
||||
opened_at=excluded.opened_at,
|
||||
closed_at=excluded.closed_at,
|
||||
opened_at_ms=excluded.opened_at_ms,
|
||||
closed_at_ms=excluded.closed_at_ms,
|
||||
hold_seconds=excluded.hold_seconds,
|
||||
realized_pnl_total=excluded.realized_pnl_total,
|
||||
status_raw=excluded.status_raw,
|
||||
pos_id=excluded.pos_id,
|
||||
inst_id=excluded.inst_id,
|
||||
opt_type=excluded.opt_type,
|
||||
strike=excluded.strike,
|
||||
exp_time=excluded.exp_time,
|
||||
sheets=excluded.sheets,
|
||||
open_avg=excluded.open_avg,
|
||||
close_avg=excluded.close_avg,
|
||||
premium_paid=excluded.premium_paid,
|
||||
realized_pnl=excluded.realized_pnl,
|
||||
hedge_plan_id=excluded.hedge_plan_id,
|
||||
plan_close_reason=excluded.plan_close_reason,
|
||||
realized_pnl_perp=excluded.realized_pnl_perp,
|
||||
realized_pnl_options=excluded.realized_pnl_options,
|
||||
premium_total=excluded.premium_total,
|
||||
direction=excluded.direction,
|
||||
tp=excluded.tp,
|
||||
sl=excluded.sl,
|
||||
target_price=excluded.target_price,
|
||||
target_price_up=excluded.target_price_up,
|
||||
target_price_down=excluded.target_price_down,
|
||||
legs_json=excluded.legs_json,
|
||||
linked_hedge_plan_id=excluded.linked_hedge_plan_id,
|
||||
excluded_as_hedge_leg=excluded.excluded_as_hedge_leg,
|
||||
strategy_tag=excluded.strategy_tag,
|
||||
result_tag=excluded.result_tag,
|
||||
reviewed=excluded.reviewed,
|
||||
source_label=excluded.source_label,
|
||||
payload_json=excluded.payload_json,
|
||||
synced_at=excluded.synced_at
|
||||
""",
|
||||
(
|
||||
ex_k,
|
||||
hk,
|
||||
t.get("source_type"),
|
||||
t.get("underlying"),
|
||||
opened_at,
|
||||
closed_at,
|
||||
int(opened_ms) if opened_ms else None,
|
||||
int(closed_ms) if closed_ms else None,
|
||||
_optional_int(t.get("hold_seconds")),
|
||||
float(t.get("realized_pnl_total") or t.get("realized_pnl") or 0),
|
||||
t.get("status_raw"),
|
||||
t.get("pos_id"),
|
||||
t.get("inst_id"),
|
||||
t.get("opt_type"),
|
||||
_optional_float(t.get("strike")),
|
||||
t.get("exp_time"),
|
||||
_optional_int(t.get("sheets")),
|
||||
_optional_float(t.get("open_avg")),
|
||||
_optional_float(t.get("close_avg")),
|
||||
_optional_float(t.get("premium_paid")),
|
||||
_optional_float(t.get("realized_pnl")),
|
||||
_optional_int(t.get("hedge_plan_id")),
|
||||
t.get("plan_close_reason"),
|
||||
_optional_float(t.get("realized_pnl_perp")),
|
||||
_optional_float(t.get("realized_pnl_options")),
|
||||
_optional_float(t.get("premium_total")),
|
||||
t.get("direction"),
|
||||
_optional_float(t.get("tp")),
|
||||
_optional_float(t.get("sl")),
|
||||
_optional_float(t.get("target_price")),
|
||||
_optional_float(t.get("target_price_up")),
|
||||
_optional_float(t.get("target_price_down")),
|
||||
t.get("legs_json")
|
||||
if isinstance(t.get("legs_json"), str)
|
||||
else (json.dumps(t.get("legs"), ensure_ascii=False) if t.get("legs") else None),
|
||||
_optional_int(t.get("linked_hedge_plan_id")),
|
||||
int(t.get("excluded_as_hedge_leg") or 0),
|
||||
strategy_tag,
|
||||
result_tag,
|
||||
reviewed,
|
||||
t.get("source_label"),
|
||||
payload,
|
||||
now,
|
||||
),
|
||||
)
|
||||
n += 1
|
||||
finally:
|
||||
conn.close()
|
||||
removed = 0
|
||||
if prune_missing:
|
||||
removed = purge_stale_options_trades_cache(ex_k, active_keys, db_path=db_path)
|
||||
return {"upserted": n, "removed": removed}
|
||||
|
||||
|
||||
def _options_row_to_dict(row: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = dict(row)
|
||||
payload = {}
|
||||
raw = out.get("payload_json")
|
||||
if raw:
|
||||
try:
|
||||
payload = json.loads(raw) if isinstance(raw, str) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
if isinstance(payload, dict):
|
||||
for k, v in payload.items():
|
||||
if k not in out or out.get(k) in (None, ""):
|
||||
out[k] = v
|
||||
pnl = float(out.get("realized_pnl_total") or out.get("realized_pnl") or 0)
|
||||
out["realized_pnl_total"] = pnl
|
||||
out["pnl_amount"] = pnl # 复用永续统计/日历字段名
|
||||
hold_sec = out.get("hold_seconds")
|
||||
if hold_sec is not None:
|
||||
try:
|
||||
out["hold_minutes"] = round(float(hold_sec) / 60.0, 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not out.get("opened_at_ms") and out.get("opened_at"):
|
||||
ms = parse_wall_clock_ms(out.get("opened_at"))
|
||||
if ms:
|
||||
out["opened_at_ms"] = int(ms)
|
||||
if not out.get("closed_at_ms") and out.get("closed_at"):
|
||||
ms = parse_wall_clock_ms(out.get("closed_at"))
|
||||
if ms:
|
||||
out["closed_at_ms"] = int(ms)
|
||||
out["trade_id"] = out.get("history_key")
|
||||
out["id"] = out.get("history_key")
|
||||
out["symbol"] = out.get("inst_id") or out.get("underlying") or ""
|
||||
return out
|
||||
|
||||
|
||||
def _empty_options_stats() -> dict[str, Any]:
|
||||
return {
|
||||
"open_count": 0,
|
||||
"sick_count": 0,
|
||||
"sick_pct": 0.0,
|
||||
"pnl_total": 0.0,
|
||||
"pnl_ex_sick": 0.0,
|
||||
"win_count": 0,
|
||||
"loss_count": 0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"max_win": 0.0,
|
||||
"max_loss": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"profit_loss_ratio": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"premium_total": 0.0,
|
||||
"by_exchange": {},
|
||||
"by_source_type": {},
|
||||
}
|
||||
|
||||
|
||||
def _compute_options_period_stats(trade_rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
st = _empty_options_stats()
|
||||
wins: list[float] = []
|
||||
losses: list[float] = []
|
||||
by_ex: dict[str, dict[str, Any]] = {}
|
||||
by_src: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def bucket() -> dict[str, Any]:
|
||||
return {
|
||||
"open_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"win_count": 0,
|
||||
"loss_count": 0,
|
||||
"premium_total": 0.0,
|
||||
}
|
||||
|
||||
for td in trade_rows:
|
||||
pnl = float(td.get("pnl_amount") or td.get("realized_pnl_total") or 0)
|
||||
ex = str(td.get("exchange_key") or "okx")
|
||||
src = str(td.get("source_type") or td.get("source_label") or "?")
|
||||
prem = float(td.get("premium_total") or td.get("premium_paid") or 0)
|
||||
st["open_count"] += 1
|
||||
st["pnl_total"] += pnl
|
||||
st["premium_total"] += prem
|
||||
if pnl > 0.0001:
|
||||
st["win_count"] += 1
|
||||
wins.append(pnl)
|
||||
elif pnl < -0.0001:
|
||||
st["loss_count"] += 1
|
||||
losses.append(pnl)
|
||||
if ex not in by_ex:
|
||||
by_ex[ex] = bucket()
|
||||
by_ex[ex]["open_count"] += 1
|
||||
by_ex[ex]["pnl_total"] += pnl
|
||||
by_ex[ex]["premium_total"] += prem
|
||||
if pnl > 0.0001:
|
||||
by_ex[ex]["win_count"] += 1
|
||||
elif pnl < -0.0001:
|
||||
by_ex[ex]["loss_count"] += 1
|
||||
if src not in by_src:
|
||||
by_src[src] = bucket()
|
||||
by_src[src]["open_count"] += 1
|
||||
by_src[src]["pnl_total"] += pnl
|
||||
|
||||
total = int(st["open_count"] or 0)
|
||||
st["pnl_ex_sick"] = round(float(st["pnl_total"]), 4)
|
||||
st["pnl_total"] = round(float(st["pnl_total"]), 4)
|
||||
st["premium_total"] = round(float(st["premium_total"]), 4)
|
||||
st["avg_win"] = round(sum(wins) / len(wins), 4) if wins else 0.0
|
||||
st["avg_loss"] = round(sum(losses) / len(losses), 4) if losses else 0.0
|
||||
st["max_win"] = round(max(wins), 4) if wins else 0.0
|
||||
st["max_loss"] = round(min(losses), 4) if losses else 0.0
|
||||
st["win_rate"] = round(st["win_count"] / total * 100, 1) if total else 0.0
|
||||
if wins and losses and abs(st["avg_loss"]) > 1e-9:
|
||||
st["profit_loss_ratio"] = round(abs(st["avg_win"] / st["avg_loss"]), 2)
|
||||
for ex, b in by_ex.items():
|
||||
b["pnl_total"] = round(float(b["pnl_total"]), 4)
|
||||
b["premium_total"] = round(float(b["premium_total"]), 4)
|
||||
b["sick_count"] = 0
|
||||
b["sick_pct"] = 0.0
|
||||
b["pnl_ex_sick"] = b["pnl_total"]
|
||||
b["avg_win"] = 0.0
|
||||
b["avg_loss"] = 0.0
|
||||
b["max_win"] = 0.0
|
||||
b["max_loss"] = 0.0
|
||||
b["win_rate"] = (
|
||||
round(b["win_count"] / b["open_count"] * 100, 1) if b["open_count"] else 0.0
|
||||
)
|
||||
b["profit_loss_ratio"] = 0.0
|
||||
b["turnover_total"] = 0.0
|
||||
b["commission_total"] = 0.0
|
||||
for src, b in by_src.items():
|
||||
b["pnl_total"] = round(float(b["pnl_total"]), 4)
|
||||
st["by_exchange"] = by_ex
|
||||
st["by_source_type"] = by_src
|
||||
return st
|
||||
|
||||
|
||||
def list_daily_options_trades(
|
||||
trading_day: str = "",
|
||||
*,
|
||||
period: str = "",
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
exchange_key: str = "",
|
||||
filter_profit: bool = False,
|
||||
filter_loss: bool = False,
|
||||
search: str = "",
|
||||
source_type: str = "",
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_options_archive_db(db_path)
|
||||
p = (period or "today").strip().lower() or "today"
|
||||
start_ms, end_ms, df, dt, period_label = resolve_period_bounds(
|
||||
period=p,
|
||||
trading_day=trading_day,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
ex_filter = (exchange_key or "").strip().lower()
|
||||
src_filter = (source_type or "").strip().lower()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
params: list[Any] = [start_ms, end_ms]
|
||||
where = "closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||
where += " AND COALESCE(excluded_as_hedge_leg,0)=0"
|
||||
if ex_filter:
|
||||
where += " AND exchange_key=?"
|
||||
params.append(ex_filter)
|
||||
if src_filter:
|
||||
where += " AND LOWER(COALESCE(source_type,''))=?"
|
||||
params.append(src_filter)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM archive_options_trade_cache
|
||||
WHERE {where}
|
||||
ORDER BY closed_at_ms DESC, history_key DESC
|
||||
""",
|
||||
params,
|
||||
).fetchall()
|
||||
trades: list[dict[str, Any]] = []
|
||||
q = (search or "").strip().lower()
|
||||
for r in rows:
|
||||
td = _options_row_to_dict(r)
|
||||
pnl = float(td.get("pnl_amount") or 0)
|
||||
if filter_profit and pnl <= 0.0001:
|
||||
continue
|
||||
if filter_loss and pnl >= -0.0001:
|
||||
continue
|
||||
if q:
|
||||
blob = " ".join(
|
||||
str(td.get(k) or "")
|
||||
for k in (
|
||||
"underlying",
|
||||
"inst_id",
|
||||
"exchange_key",
|
||||
"source_type",
|
||||
"source_label",
|
||||
"opt_type",
|
||||
"strategy_tag",
|
||||
"result_tag",
|
||||
"direction",
|
||||
)
|
||||
).lower()
|
||||
if q not in blob:
|
||||
continue
|
||||
trades.append(td)
|
||||
return {
|
||||
"period": p,
|
||||
"period_label": period_label,
|
||||
"trading_day": dt,
|
||||
"date_from": df,
|
||||
"date_to": dt,
|
||||
"product": "options",
|
||||
"trades": trades,
|
||||
"stats": _compute_options_period_stats(trades),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_archive_options_calendar(
|
||||
year: int,
|
||||
month: int,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
db_path: Path | None = None,
|
||||
reset_hour: int = TRADING_DAY_RESET_HOUR,
|
||||
) -> dict[str, Any]:
|
||||
init_options_archive_db(db_path)
|
||||
y = int(year)
|
||||
m = int(month)
|
||||
if m < 1 or m > 12:
|
||||
raise ValueError("month 无效")
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
first = f"{y:04d}-{m:02d}-01"
|
||||
if m == 12:
|
||||
next_first = datetime(y + 1, 1, 1)
|
||||
else:
|
||||
next_first = datetime(y, m + 1, 1)
|
||||
last = (next_first - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
start_ms, _ = trading_day_bounds_ms(first, reset_hour=reset_hour)
|
||||
_, end_ms = trading_day_bounds_ms(last, reset_hour=reset_hour)
|
||||
ex_filter = (exchange_key or "").strip().lower()
|
||||
conn = _connect(db_path)
|
||||
try:
|
||||
params: list[Any] = [start_ms, end_ms]
|
||||
where = (
|
||||
"closed_at_ms IS NOT NULL AND closed_at_ms >= ? AND closed_at_ms < ?"
|
||||
" AND COALESCE(excluded_as_hedge_leg,0)=0"
|
||||
)
|
||||
if ex_filter:
|
||||
where += " AND exchange_key=?"
|
||||
params.append(ex_filter)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM archive_options_trade_cache WHERE {where}",
|
||||
params,
|
||||
).fetchall()
|
||||
days: dict[str, dict[str, Any]] = {}
|
||||
for r in rows:
|
||||
td = _options_row_to_dict(r)
|
||||
closed_ms = td.get("closed_at_ms") or parse_wall_clock_ms(td.get("closed_at"))
|
||||
if not closed_ms:
|
||||
continue
|
||||
day = ms_to_trading_day(int(closed_ms), reset_hour=reset_hour)
|
||||
if not day or day < first or day > last:
|
||||
continue
|
||||
bucket = days.setdefault(
|
||||
day,
|
||||
{
|
||||
"trading_day": day,
|
||||
"open_count": 0,
|
||||
"sick_count": 0,
|
||||
"pnl_total": 0.0,
|
||||
"turnover_total": 0.0,
|
||||
"commission_total": 0.0,
|
||||
"has_sick": False,
|
||||
},
|
||||
)
|
||||
bucket["open_count"] += 1
|
||||
bucket["pnl_total"] += float(td.get("pnl_amount") or 0)
|
||||
for d in days.values():
|
||||
d["pnl_total"] = round(float(d["pnl_total"]), 4)
|
||||
month_pnl = sum(float(d["pnl_total"]) for d in days.values())
|
||||
month_count = sum(int(d["open_count"]) for d in days.values())
|
||||
return {
|
||||
"year": y,
|
||||
"month": m,
|
||||
"date_from": first,
|
||||
"date_to": last,
|
||||
"product": "options",
|
||||
"days": days,
|
||||
"month_pnl_total": round(month_pnl, 4),
|
||||
"month_open_count": month_count,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sync_options_exchange_archive(
|
||||
exchange_key: str,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""仅缓存期权交易,不做 K 线."""
|
||||
r = upsert_options_trades_cache(
|
||||
exchange_key, trades, db_path=db_path, prune_missing=True
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange_key": (exchange_key or "").strip().lower(),
|
||||
"product": "options",
|
||||
"trades_upserted": r.get("upserted", 0),
|
||||
"trades_removed": r.get("removed", 0),
|
||||
"trade_count": len(trades or []),
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _safe_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
v = float(value)
|
||||
return v if v >= 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _account_total_usdt(funding: Any, trading: Any) -> Optional[float]:
|
||||
fu = _safe_float(funding)
|
||||
tu = _safe_float(trading)
|
||||
if fu is None or tu is None:
|
||||
return None
|
||||
return round(fu + tu, 4)
|
||||
|
||||
|
||||
def stablecoin_usdt_equiv(value: Any) -> Optional[float]:
|
||||
"""USDC / USDT 按 1:1 折算为 USDT 统计口径."""
|
||||
return _safe_float(value)
|
||||
|
||||
|
||||
def _sum_optional(*values: Any) -> Optional[float]:
|
||||
parts = [_safe_float(v) for v in values]
|
||||
present = [p for p in parts if p is not None]
|
||||
if not present:
|
||||
return None
|
||||
return round(sum(present), 4)
|
||||
|
||||
|
||||
def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""从期权 snapshot 提取资金户/交易户 USDT 等价余额.
|
||||
|
||||
- funding_usdt / trading_usdt: USDT+USDC(+USDG) 全账户(勿与永续 USDT 再加总)
|
||||
- funding_usdc_equiv / trading_usdc_equiv: 仅非 USDT 稳定币,可安全加到永续 USDT 上
|
||||
"""
|
||||
snap = options_snap if isinstance(options_snap, dict) else {}
|
||||
if snap.get("enabled") is False:
|
||||
return {
|
||||
"ok": False,
|
||||
"funding_usdt": None,
|
||||
"trading_usdt": None,
|
||||
"funding_usdc_equiv": None,
|
||||
"trading_usdc_equiv": None,
|
||||
}
|
||||
if snap.get("ok") is False:
|
||||
return {
|
||||
"ok": False,
|
||||
"funding_usdt": None,
|
||||
"trading_usdt": None,
|
||||
"funding_usdc_equiv": None,
|
||||
"trading_usdc_equiv": None,
|
||||
}
|
||||
bal = snap.get("balances") if isinstance(snap.get("balances"), dict) else snap
|
||||
funding_usdc_equiv = _sum_optional(bal.get("funding_usdc"), bal.get("funding_usdg"))
|
||||
trading_usdc_equiv = _sum_optional(bal.get("trading_usdc"), bal.get("trading_usdg"))
|
||||
funding = _sum_optional(bal.get("funding_usdt"), funding_usdc_equiv)
|
||||
trading = _sum_optional(bal.get("trading_usdt"), trading_usdc_equiv)
|
||||
ok = funding is not None and trading is not None
|
||||
return {
|
||||
"ok": ok,
|
||||
"funding_usdt": funding,
|
||||
"trading_usdt": trading,
|
||||
"funding_usdc_equiv": funding_usdc_equiv,
|
||||
"trading_usdc_equiv": trading_usdc_equiv,
|
||||
}
|
||||
|
||||
|
||||
def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
|
||||
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U.
|
||||
|
||||
优先逐仓 display_pnl_usdt_from_option_row(已含 BTC/ETH × 指数);
|
||||
回退 upl_total_usdc 时按「已是 USDT」处理——实例 hub 快照合计已折 U,
|
||||
不可再 × 指数,否则币本位会双计.
|
||||
"""
|
||||
snap = options_snap if isinstance(options_snap, dict) else {}
|
||||
if snap.get("enabled") is False or snap.get("ok") is False:
|
||||
return None
|
||||
|
||||
try:
|
||||
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
|
||||
|
||||
total_u = 0.0
|
||||
found = False
|
||||
for p in snap.get("positions") or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
pnl = display_pnl_usdt_from_option_row(p)
|
||||
if pnl is None:
|
||||
continue
|
||||
found = True
|
||||
total_u += float(pnl)
|
||||
if found:
|
||||
return round(total_u, 4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
upl = snap.get("upl_total_usdc")
|
||||
if upl is not None:
|
||||
try:
|
||||
return round(float(upl), 4)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def options_open_position_count(options_snap: dict[str, Any] | None) -> int:
|
||||
snap = options_snap if isinstance(options_snap, dict) else {}
|
||||
if snap.get("enabled") is False or snap.get("ok") is False:
|
||||
return 0
|
||||
if snap.get("position_count") is not None:
|
||||
try:
|
||||
return max(0, int(snap.get("position_count")))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
pos = snap.get("positions")
|
||||
return len(pos) if isinstance(pos, list) else 0
|
||||
|
||||
|
||||
def merge_perp_options_balances(
|
||||
perpetual_funding_usdt: Any,
|
||||
perpetual_trading_usdt: Any,
|
||||
options_snap: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""永续 USDT + 期权非 USDT 稳定币合并为中控总资金(避免 OKX 同账户 USDT 双计).
|
||||
|
||||
与实例顶栏 total_funds_usdt(..., options_usdc, None, None) 口径一致:
|
||||
期权 snapshot 里的 USDT 与永续资金/交易户是同一钱包,只把 USDC/USDG 加上.
|
||||
"""
|
||||
opt = options_balances_usdt_equiv(options_snap)
|
||||
# 展示用期权户:优先非 USDT 稳定币;若仅有 USDT 则仍给出全量以便辨识
|
||||
opt_fund_disp = opt.get("funding_usdc_equiv")
|
||||
opt_trade_disp = opt.get("trading_usdc_equiv")
|
||||
if opt_fund_disp is None and opt_trade_disp is None and opt.get("ok"):
|
||||
opt_fund_disp = opt.get("funding_usdt")
|
||||
opt_trade_disp = opt.get("trading_usdt")
|
||||
|
||||
if opt.get("ok"):
|
||||
if perpetual_funding_usdt is None and perpetual_trading_usdt is None:
|
||||
# 永续账户未取到时,期权 snapshot 已含同账户 USDT+USDC,直接用全量
|
||||
funding = opt.get("funding_usdt")
|
||||
trading = opt.get("trading_usdt")
|
||||
else:
|
||||
funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdc_equiv"))
|
||||
trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdc_equiv"))
|
||||
else:
|
||||
funding = _safe_float(perpetual_funding_usdt)
|
||||
trading = _safe_float(perpetual_trading_usdt)
|
||||
|
||||
total = _account_total_usdt(funding, trading)
|
||||
# 任一侧齐全即可展示;永续缺一侧但有期权 USDC 时仍尽量给出合计
|
||||
if total is None:
|
||||
total = _sum_optional(funding, trading)
|
||||
perp_total = _account_total_usdt(perpetual_funding_usdt, perpetual_trading_usdt)
|
||||
opt_total = _account_total_usdt(opt.get("funding_usdt"), opt.get("trading_usdt"))
|
||||
data_ok = total is not None
|
||||
return {
|
||||
"perpetual_funding_usdt": _safe_float(perpetual_funding_usdt),
|
||||
"perpetual_trading_usdt": _safe_float(perpetual_trading_usdt),
|
||||
"options_funding_usdt": opt_fund_disp,
|
||||
"options_trading_usdt": opt_trade_disp,
|
||||
"options_funding_full_usdt": opt.get("funding_usdt"),
|
||||
"options_trading_full_usdt": opt.get("trading_usdt"),
|
||||
"options_ok": bool(opt.get("ok")),
|
||||
"funding_usdt": funding,
|
||||
"trading_usdt": trading,
|
||||
"total_usdt": total,
|
||||
"perpetual_total_usdt": perp_total,
|
||||
"options_total_usdt": opt_total,
|
||||
"data_ok": data_ok,
|
||||
}
|
||||
|
||||
|
||||
def repair_double_counted_fund_entry(ac: dict[str, Any]) -> dict[str, Any]:
|
||||
"""识别并修复历史快照中「永续 USDT + 期权(USDT+USDC)」的双计.
|
||||
|
||||
旧口径 options_* 存的是 USDT+USDC 全量,且 funding≈2×期权资金户 USDT 部分.
|
||||
新口径 options_* 多为纯 USDC,不会误伤.
|
||||
"""
|
||||
if not isinstance(ac, dict):
|
||||
return {}
|
||||
out = dict(ac)
|
||||
ofu = _safe_float(ac.get("options_funding_usdt"))
|
||||
otu = _safe_float(ac.get("options_trading_usdt"))
|
||||
fu = _safe_float(ac.get("funding_usdt"))
|
||||
tu = _safe_float(ac.get("trading_usdt"))
|
||||
if ofu is None or otu is None or fu is None or tu is None:
|
||||
return out
|
||||
if ofu < 1.0:
|
||||
return out
|
||||
ratio = fu / ofu if ofu > 0 else 0.0
|
||||
# 经典双计:合并资金户 ≈ 2 × 期权资金户(同钱包 USDT 加了两遍)
|
||||
if 1.8 <= ratio <= 2.25:
|
||||
out["funding_usdt"] = ofu
|
||||
out["trading_usdt"] = otu
|
||||
out["total_usdt"] = round(ofu + otu, 4)
|
||||
out["repaired_double_count"] = True
|
||||
return out
|
||||
|
||||
|
||||
def merge_board_row_balances(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""监控板行 → 含期权的资金统计."""
|
||||
caps = row.get("capabilities") or []
|
||||
options_snap = row.get("options") if "options" in caps else None
|
||||
merged = merge_perp_options_balances(
|
||||
row.get("funding_usdt") if row.get("account_ok") else None,
|
||||
row.get("trading_usdt") if row.get("account_ok") else None,
|
||||
options_snap,
|
||||
)
|
||||
merged["options_float_pnl_u"] = options_float_pnl_usdt(options_snap)
|
||||
merged["options_open_position_count"] = options_open_position_count(options_snap)
|
||||
return merged
|
||||
@@ -0,0 +1,114 @@
|
||||
"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def cond_order_role(row: dict[str, Any]) -> str | None:
|
||||
lbl = (row.get("label") or "").strip().lower()
|
||||
if "止损" in lbl and "止盈止损" not in lbl:
|
||||
return "sl"
|
||||
if "止盈" in lbl:
|
||||
return "tp"
|
||||
return None
|
||||
|
||||
|
||||
def dedupe_conditional_orders_by_role(orders: list) -> list:
|
||||
"""同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复)."""
|
||||
if not orders:
|
||||
return []
|
||||
by_role: dict[str, dict] = {}
|
||||
others: list[dict] = []
|
||||
for row in orders:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
role = cond_order_role(row)
|
||||
if role:
|
||||
by_role[role] = row
|
||||
else:
|
||||
others.append(row)
|
||||
out = list(others)
|
||||
for role in ("tp", "sl"):
|
||||
if role in by_role:
|
||||
out.append(by_role[role])
|
||||
return out
|
||||
|
||||
|
||||
def exchange_tpsl_from_cond_orders(cond: list) -> dict[str, Any] | None:
|
||||
"""从子代理条件单列表还原 exchange_tpsl 槽位."""
|
||||
slots: dict[str, Any] = {"sl": None, "tp": None}
|
||||
for row in cond or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
role = cond_order_role(row)
|
||||
if role not in ("sl", "tp"):
|
||||
continue
|
||||
trig = row.get("trigger_price")
|
||||
if trig is None:
|
||||
continue
|
||||
try:
|
||||
trig_f = float(trig)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
oid = row.get("algo_id") or row.get("id") or ""
|
||||
slots[role] = {
|
||||
"order_id": str(oid) if oid not in (None, "") else "",
|
||||
"trigger_price": trig_f,
|
||||
"trigger_display": f"{trig_f:g}",
|
||||
"amount": row.get("amount"),
|
||||
"type": row.get("type") or "",
|
||||
}
|
||||
if not slots["sl"] and not slots["tp"]:
|
||||
return None
|
||||
return slots
|
||||
|
||||
|
||||
def sync_active_monitor_tpsl_prices(
|
||||
conn,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
stop_loss: float,
|
||||
take_profit: float,
|
||||
*,
|
||||
symbols_match: Callable[[str, str], bool],
|
||||
) -> dict[str, Any]:
|
||||
"""按 symbol+方向更新 active 下单监控的 stop_loss / take_profit."""
|
||||
sym = (symbol or "").strip()
|
||||
side = (direction or "").strip().lower()
|
||||
if not sym:
|
||||
return {"ok": False, "msg": "symbol 不能为空"}
|
||||
if side not in ("long", "short"):
|
||||
return {"ok": False, "msg": "side 须为 long 或 short"}
|
||||
try:
|
||||
sl = float(stop_loss)
|
||||
tp = float(take_profit)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "msg": "stop_loss / take_profit 须为数字"}
|
||||
if sl <= 0 or tp <= 0:
|
||||
return {"ok": False, "msg": "止损,止盈须大于 0"}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'"
|
||||
).fetchall()
|
||||
updated_ids: list[int] = []
|
||||
for row in rows:
|
||||
r_sym = row["exchange_symbol"] if "exchange_symbol" in row.keys() else row["symbol"]
|
||||
r_sym = r_sym or row["symbol"]
|
||||
if not symbols_match(sym, r_sym or ""):
|
||||
continue
|
||||
r_dir = (row["direction"] or "").strip().lower()
|
||||
if r_dir and r_dir != side:
|
||||
continue
|
||||
oid = int(row["id"])
|
||||
conn.execute(
|
||||
"UPDATE order_monitors SET stop_loss=?, take_profit=? WHERE id=? AND status='active'",
|
||||
(sl, tp, oid),
|
||||
)
|
||||
updated_ids.append(oid)
|
||||
return {
|
||||
"ok": True,
|
||||
"updated": len(updated_ids),
|
||||
"order_monitor_ids": updated_ids,
|
||||
"stop_loss": sl,
|
||||
"take_profit": tp,
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"""中控永期对冲计算器:永续 1 币 + 按目标盈利反推期权仓位/波动点数(纯函数)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt, taker_fee_rate
|
||||
|
||||
DEFAULT_CT_MULT = 0.01
|
||||
PERP_COINS = 1.0
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_base_common(
|
||||
*,
|
||||
base: str,
|
||||
spot: Any,
|
||||
capital_usdt: Any,
|
||||
target_profit_u: Any,
|
||||
perp_leverage: Any,
|
||||
option_leverage: Any,
|
||||
ct_mult: Any,
|
||||
) -> Tuple[Optional[dict[str, float]], Optional[str]]:
|
||||
b = (base or "ETH").strip().upper()
|
||||
if b not in ("ETH", "BTC"):
|
||||
return None, "币种仅支持 BTC / ETH"
|
||||
s = _f(spot)
|
||||
capital = _f(capital_usdt)
|
||||
target = _f(target_profit_u)
|
||||
p_lev = _f(perp_leverage)
|
||||
o_lev = _f(option_leverage)
|
||||
ct = _f(ct_mult)
|
||||
if s is None or capital is None or target is None or p_lev is None or o_lev is None:
|
||||
return None, "参数格式错误"
|
||||
if ct is None or ct <= 0:
|
||||
ct = DEFAULT_CT_MULT
|
||||
if s <= 0 or capital <= 0 or p_lev <= 0 or o_lev <= 0:
|
||||
return None, "现价、资金、杠杆须大于 0"
|
||||
if target < 0:
|
||||
return None, "目标盈利不能为负"
|
||||
prem_per_coin = s / o_lev
|
||||
if prem_per_coin <= 0:
|
||||
return None, "单币权利金无效"
|
||||
margin = (s * PERP_COINS) / p_lev
|
||||
return {
|
||||
"base_ok": 1.0,
|
||||
"spot": s,
|
||||
"capital": capital,
|
||||
"target": target,
|
||||
"p_lev": p_lev,
|
||||
"o_lev": o_lev,
|
||||
"ct": ct,
|
||||
"prem_per_coin": prem_per_coin,
|
||||
"margin": margin,
|
||||
"fee_rate": taker_fee_rate(),
|
||||
}, None
|
||||
|
||||
|
||||
def _move_for_perp_correct(
|
||||
*,
|
||||
spot: float,
|
||||
target: float,
|
||||
premium: float,
|
||||
fee_rate: float,
|
||||
perp_coins: float = 1.0,
|
||||
) -> float:
|
||||
"""净利 = qty*move − premium − fee(move,qty) = target → 解 move.
|
||||
|
||||
fee = (2*spot + move) * qty * fee_rate
|
||||
qty*move*(1-fee_rate) = target + premium + 2*spot*qty*fee_rate
|
||||
"""
|
||||
qty = float(perp_coins)
|
||||
if qty <= 0:
|
||||
return 0.0
|
||||
denom = qty * (1.0 - float(fee_rate))
|
||||
if denom <= 0:
|
||||
return 0.0
|
||||
return (float(target) + float(premium) + 2.0 * float(spot) * qty * float(fee_rate)) / denom
|
||||
|
||||
|
||||
def _case_sideways(
|
||||
*,
|
||||
spot: float,
|
||||
premium_total: float,
|
||||
perp_coins: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""横盘/到期无方向:永续≈0,期权权利金全亏,另计永续开平同价手续费.
|
||||
|
||||
最大亏损(正数) = 权利金总额 + 开平手续费(exit=entry)
|
||||
组合净利 = −最大亏损
|
||||
"""
|
||||
qty = float(perp_coins) if float(perp_coins) > 0 else PERP_COINS
|
||||
fee_flat = estimate_roundtrip_fee_usdt(spot, spot, qty=qty, contract_size=1.0)
|
||||
prem = float(premium_total)
|
||||
max_loss = prem + float(fee_flat)
|
||||
return {
|
||||
"label": "横盘",
|
||||
"perp_pnl_u": 0.0,
|
||||
"premium_u": round(prem, 8),
|
||||
"fee_u": round(float(fee_flat), 8),
|
||||
"max_loss_u": round(max_loss, 8),
|
||||
"net_u": round(-max_loss, 8),
|
||||
}
|
||||
|
||||
|
||||
def calc_perp_options_hedge(
|
||||
*,
|
||||
base: str = "ETH",
|
||||
spot: float,
|
||||
capital_usdt: float,
|
||||
target_profit_u: float,
|
||||
move_mode: str = "points",
|
||||
move_value: float,
|
||||
perp_leverage: float,
|
||||
option_leverage: float,
|
||||
ct_mult: float = DEFAULT_CT_MULT,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""由波动反推期权开仓币数/张数(calc_mode=size)."""
|
||||
common, err = _parse_base_common(
|
||||
base=base,
|
||||
spot=spot,
|
||||
capital_usdt=capital_usdt,
|
||||
target_profit_u=target_profit_u,
|
||||
perp_leverage=perp_leverage,
|
||||
option_leverage=option_leverage,
|
||||
ct_mult=ct_mult,
|
||||
)
|
||||
if err or not common:
|
||||
return None, err
|
||||
|
||||
s = common["spot"]
|
||||
capital = common["capital"]
|
||||
target = common["target"]
|
||||
p_lev = common["p_lev"]
|
||||
o_lev = common["o_lev"]
|
||||
ct = common["ct"]
|
||||
prem_per_coin = common["prem_per_coin"]
|
||||
margin = common["margin"]
|
||||
fee_rate = common["fee_rate"]
|
||||
b = (base or "ETH").strip().upper()
|
||||
|
||||
move = _f(move_value)
|
||||
mode = (move_mode or "points").strip().lower()
|
||||
if mode not in ("points", "pct", "percent", "rate"):
|
||||
return None, "波动模式须为 points 或 pct"
|
||||
if mode in ("percent", "rate"):
|
||||
mode = "pct"
|
||||
if move is None:
|
||||
return None, "参数格式错误"
|
||||
if move <= 0:
|
||||
return None, "现价、资金、波动、杠杆须大于 0"
|
||||
|
||||
if mode == "pct":
|
||||
move_points = s * (move / 100.0)
|
||||
else:
|
||||
move_points = move
|
||||
if move_points <= 0:
|
||||
return None, "波动对应价格变动须大于 0"
|
||||
|
||||
exit_px = s + move_points
|
||||
perp_gross = move_points * PERP_COINS
|
||||
fee = estimate_roundtrip_fee_usdt(s, exit_px, qty=PERP_COINS, contract_size=1.0)
|
||||
|
||||
premium_budget = perp_gross - target - fee
|
||||
if premium_budget <= 0:
|
||||
return None, "波动收益不足以覆盖目标盈利+手续费,无法开期权"
|
||||
|
||||
opt_coins = premium_budget / prem_per_coin
|
||||
opt_sheets = opt_coins / ct
|
||||
premium_total = opt_coins * prem_per_coin
|
||||
|
||||
case_a_net = perp_gross - premium_total - fee
|
||||
opt_intrinsic = opt_coins * move_points
|
||||
opt_net = opt_intrinsic - premium_total
|
||||
perp_loss = -perp_gross
|
||||
portfolio_net = opt_net + perp_loss
|
||||
|
||||
return {
|
||||
"calc_mode": "size",
|
||||
"base": b,
|
||||
"spot": round(s, 8),
|
||||
"capital_usdt": round(capital, 8),
|
||||
"target_profit_u": round(target, 8),
|
||||
"move_mode": mode,
|
||||
"move_value": round(move, 8),
|
||||
"move_points": round(move_points, 8),
|
||||
"exit_price": round(exit_px, 8),
|
||||
"perp_coins": PERP_COINS,
|
||||
"perp_leverage": round(p_lev, 8),
|
||||
"option_leverage": round(o_lev, 8),
|
||||
"ct_mult": ct,
|
||||
"prem_per_coin": round(prem_per_coin, 8),
|
||||
"perp_gross_u": round(perp_gross, 8),
|
||||
"perp_fee_u": round(fee, 8),
|
||||
"fee_rate": fee_rate,
|
||||
"premium_budget_u": round(premium_budget, 8),
|
||||
"opt_coins": round(opt_coins, 8),
|
||||
"opt_sheets": round(opt_sheets, 8),
|
||||
"premium_total_u": round(premium_total, 8),
|
||||
"perp_margin_u": round(margin, 8),
|
||||
"capital_ok": bool(capital >= margin),
|
||||
"case_a": {
|
||||
"label": "永续方向对",
|
||||
"perp_pnl_u": round(perp_gross, 8),
|
||||
"premium_u": round(premium_total, 8),
|
||||
"fee_u": round(fee, 8),
|
||||
"net_u": round(case_a_net, 8),
|
||||
},
|
||||
"case_b": {
|
||||
"label": "期权方向对",
|
||||
"opt_intrinsic_u": round(opt_intrinsic, 8),
|
||||
"premium_u": round(premium_total, 8),
|
||||
"opt_net_u": round(opt_net, 8),
|
||||
"perp_pnl_u": round(perp_loss, 8),
|
||||
"portfolio_net_u": round(portfolio_net, 8),
|
||||
},
|
||||
"case_sideways": _case_sideways(spot=s, premium_total=premium_total),
|
||||
}, None
|
||||
|
||||
|
||||
def calc_perp_options_points(
|
||||
*,
|
||||
base: str = "ETH",
|
||||
spot: float,
|
||||
capital_usdt: float,
|
||||
target_profit_u: float,
|
||||
perp_leverage: float,
|
||||
option_leverage: float,
|
||||
ratio_perp: float = 1.0,
|
||||
ratio_opt: float = 2.0,
|
||||
ct_mult: float = DEFAULT_CT_MULT,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""按永续/期权币数 + 目标盈利,反推两套情景所需波动点数.
|
||||
|
||||
永续币数 = ratio_perp, 期权币数 = ratio_opt(按绝对币数,不再归一到 1 币).
|
||||
例 2:4 → 永续 2 币 + 期权 4 币;1:2 → 永续 1 币 + 期权 2 币.
|
||||
|
||||
A 永续方向对: qty*move − premium − fee(move,qty) = 目标盈利
|
||||
B 期权方向对:
|
||||
- 期权净利达目标: opt_coins*move − premium = 目标
|
||||
- 组合净利达目标: move*(opt_coins − perp_coins) − premium = 目标
|
||||
"""
|
||||
common, err = _parse_base_common(
|
||||
base=base,
|
||||
spot=spot,
|
||||
capital_usdt=capital_usdt,
|
||||
target_profit_u=target_profit_u,
|
||||
perp_leverage=perp_leverage,
|
||||
option_leverage=option_leverage,
|
||||
ct_mult=ct_mult,
|
||||
)
|
||||
if err or not common:
|
||||
return None, err
|
||||
|
||||
rp = _f(ratio_perp)
|
||||
ro = _f(ratio_opt)
|
||||
if rp is None or ro is None or rp <= 0 or ro <= 0:
|
||||
return None, "永续/期权币数须大于 0"
|
||||
|
||||
s = common["spot"]
|
||||
capital = common["capital"]
|
||||
target = common["target"]
|
||||
p_lev = common["p_lev"]
|
||||
o_lev = common["o_lev"]
|
||||
ct = common["ct"]
|
||||
prem_per_coin = common["prem_per_coin"]
|
||||
fee_rate = common["fee_rate"]
|
||||
b = (base or "ETH").strip().upper()
|
||||
|
||||
perp_coins = rp
|
||||
opt_coins = ro
|
||||
premium_total = opt_coins * prem_per_coin
|
||||
opt_sheets = opt_coins / ct
|
||||
margin = (s * perp_coins) / p_lev
|
||||
|
||||
move_a = _move_for_perp_correct(
|
||||
spot=s,
|
||||
target=target,
|
||||
premium=premium_total,
|
||||
fee_rate=fee_rate,
|
||||
perp_coins=perp_coins,
|
||||
)
|
||||
if move_a <= 0:
|
||||
return None, "无法解出永续方向对所需点数"
|
||||
|
||||
fee_a = estimate_roundtrip_fee_usdt(s, s + move_a, qty=perp_coins, contract_size=1.0)
|
||||
net_a = move_a * perp_coins - premium_total - fee_a
|
||||
|
||||
# 期权净利 = 目标
|
||||
move_b_opt = (target + premium_total) / opt_coins
|
||||
opt_net_at_b_opt = opt_coins * move_b_opt - premium_total
|
||||
portfolio_at_b_opt = opt_net_at_b_opt - move_b_opt * perp_coins
|
||||
|
||||
# 组合净利 = 目标
|
||||
edge = opt_coins - perp_coins
|
||||
if edge <= 0:
|
||||
move_b_port = None
|
||||
port_err = "期权币数须大于永续币数,组合才能在方向对时赚到目标盈利"
|
||||
else:
|
||||
move_b_port = (target + premium_total) / edge
|
||||
port_err = None
|
||||
if move_b_port is not None:
|
||||
opt_net_at_b_port = opt_coins * move_b_port - premium_total
|
||||
portfolio_at_b_port = opt_net_at_b_port - move_b_port * perp_coins
|
||||
else:
|
||||
opt_net_at_b_port = None
|
||||
portfolio_at_b_port = None
|
||||
|
||||
return {
|
||||
"calc_mode": "points",
|
||||
"base": b,
|
||||
"spot": round(s, 8),
|
||||
"capital_usdt": round(capital, 8),
|
||||
"target_profit_u": round(target, 8),
|
||||
"ratio_perp": round(rp, 8),
|
||||
"ratio_opt": round(ro, 8),
|
||||
"ratio_label": f"{_fmt_ratio(rp)}:{_fmt_ratio(ro)}",
|
||||
"perp_coins": round(perp_coins, 8),
|
||||
"opt_coins": round(opt_coins, 8),
|
||||
"opt_sheets": round(opt_sheets, 8),
|
||||
"perp_leverage": round(p_lev, 8),
|
||||
"option_leverage": round(o_lev, 8),
|
||||
"ct_mult": ct,
|
||||
"prem_per_coin": round(prem_per_coin, 8),
|
||||
"premium_total_u": round(premium_total, 8),
|
||||
"fee_rate": fee_rate,
|
||||
"perp_margin_u": round(margin, 8),
|
||||
"capital_ok": bool(capital >= margin),
|
||||
"case_a": {
|
||||
"label": "永续方向对",
|
||||
"move_points": round(move_a, 8),
|
||||
"move_pct": round(move_a / s * 100.0, 8),
|
||||
"perp_pnl_u": round(move_a * perp_coins, 8),
|
||||
"premium_u": round(premium_total, 8),
|
||||
"fee_u": round(fee_a, 8),
|
||||
"net_u": round(net_a, 8),
|
||||
},
|
||||
"case_b": {
|
||||
"label": "期权方向对",
|
||||
"move_points_opt_net": round(move_b_opt, 8),
|
||||
"move_pct_opt_net": round(move_b_opt / s * 100.0, 8),
|
||||
"opt_net_u": round(opt_net_at_b_opt, 8),
|
||||
"portfolio_net_at_opt_target_u": round(portfolio_at_b_opt, 8),
|
||||
"move_points_portfolio": None if move_b_port is None else round(move_b_port, 8),
|
||||
"move_pct_portfolio": None
|
||||
if move_b_port is None
|
||||
else round(move_b_port / s * 100.0, 8),
|
||||
"opt_net_at_portfolio_target_u": None
|
||||
if opt_net_at_b_port is None
|
||||
else round(opt_net_at_b_port, 8),
|
||||
"portfolio_net_u": None if portfolio_at_b_port is None else round(portfolio_at_b_port, 8),
|
||||
"portfolio_error": port_err,
|
||||
"premium_u": round(premium_total, 8),
|
||||
},
|
||||
"case_sideways": _case_sideways(
|
||||
spot=s, premium_total=premium_total, perp_coins=perp_coins
|
||||
),
|
||||
}, None
|
||||
|
||||
|
||||
def _fmt_ratio(v: float) -> str:
|
||||
if abs(v - round(v)) < 1e-9:
|
||||
return str(int(round(v)))
|
||||
s = f"{v:.4f}".rstrip("0").rstrip(".")
|
||||
return s
|
||||
|
||||
|
||||
def calc_perp_options(
|
||||
*,
|
||||
calc_mode: str = "size",
|
||||
**kwargs: Any,
|
||||
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
|
||||
"""统一入口:size=由波动推仓位;points=由比例推点数."""
|
||||
mode = (calc_mode or "size").strip().lower()
|
||||
if mode in ("points", "ratio", "move"):
|
||||
return calc_perp_options_points(**kwargs)
|
||||
# size mode: ignore ratio kwargs if present
|
||||
kwargs.pop("ratio_perp", None)
|
||||
kwargs.pop("ratio_opt", None)
|
||||
return calc_perp_options_hedge(**kwargs)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""中控后台轮询等待:防止 request_refresh 连锁打满 CPU."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
|
||||
async def wait_poll_interval(
|
||||
*,
|
||||
refresh: asyncio.Event,
|
||||
stop: asyncio.Event,
|
||||
interval_sec: float,
|
||||
started_at: float,
|
||||
min_early_wake_sec: float | None = None,
|
||||
) -> None:
|
||||
"""距 started_at 至少间隔 interval_sec 再进入下一轮.
|
||||
|
||||
期间若收到 refresh:仅当已过 min_early_wake_sec 才提前结束(兼顾手动刷新与防抖).
|
||||
"""
|
||||
interval = max(0.05, float(interval_sec))
|
||||
min_early = (
|
||||
float(min_early_wake_sec)
|
||||
if min_early_wake_sec is not None
|
||||
else min(2.0, interval * 0.4)
|
||||
)
|
||||
while not stop.is_set():
|
||||
left = interval - (time.monotonic() - started_at)
|
||||
if left <= 0:
|
||||
return
|
||||
refresh.clear()
|
||||
stop_task = asyncio.create_task(stop.wait())
|
||||
refresh_task = asyncio.create_task(refresh.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{stop_task, refresh_task},
|
||||
timeout=left,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if stop.is_set():
|
||||
return
|
||||
if not done:
|
||||
return
|
||||
if refresh_task in done and (time.monotonic() - started_at) >= min_early:
|
||||
return
|
||||
@@ -0,0 +1,310 @@
|
||||
"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def _finite_or_none(x: Any) -> float | None:
|
||||
try:
|
||||
f = float(x)
|
||||
return f if math.isfinite(f) else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_float(*values: Any) -> float | None:
|
||||
for v in values:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
px = _finite_or_none(v)
|
||||
if px is not None and px > 0:
|
||||
return px
|
||||
return None
|
||||
|
||||
|
||||
# OKX ccxt: ETH/USD:USD-260806-1875-C ; instId: ETH-USD-260806-1875-C
|
||||
_OPTION_SYM_RE = re.compile(
|
||||
r"(?:^|[/:])[A-Z0-9]+(?:-USD)?(?::USD)?-\d{6}-\d+-(?:C|P|CALL|PUT)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_option_like_position(pos: dict[str, Any] | None) -> bool:
|
||||
"""识别期权仓(子代理/中控浮盈合计须排除,避免按永续线性公式误算)."""
|
||||
if not isinstance(pos, dict):
|
||||
return False
|
||||
info = pos.get("info") if isinstance(pos.get("info"), dict) else {}
|
||||
inst_type = str(
|
||||
info.get("instType")
|
||||
or info.get("inst_type")
|
||||
or pos.get("type")
|
||||
or ""
|
||||
).upper()
|
||||
if inst_type in ("OPTION", "OPT"):
|
||||
return True
|
||||
sym = str(
|
||||
pos.get("symbol")
|
||||
or info.get("instId")
|
||||
or info.get("instrument_name")
|
||||
or info.get("contract")
|
||||
or ""
|
||||
).strip()
|
||||
if not sym:
|
||||
return False
|
||||
if _OPTION_SYM_RE.search(sym.replace(" ", "")):
|
||||
return True
|
||||
su = sym.upper()
|
||||
if su.endswith("-C") or su.endswith("-P") or su.endswith("-CALL") or su.endswith("-PUT"):
|
||||
# 永续多为 BTC/USDT:USDT;期权常带到期日段
|
||||
if re.search(r"-\d{6}-\d+-(?:C|P|CALL|PUT)$", su):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
CONTRACTS_QTY_DECIMALS = 2
|
||||
|
||||
|
||||
def normalize_contracts_qty(qty: Any, *, decimals: int = CONTRACTS_QTY_DECIMALS) -> float:
|
||||
"""张数统一精度(OKX 等线性永续默认两位小数)."""
|
||||
try:
|
||||
q = float(qty)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
if not math.isfinite(q):
|
||||
return 0.0
|
||||
return round(abs(q), decimals)
|
||||
|
||||
|
||||
def contracts_qty_is_open(qty: Any, *, decimals: int = CONTRACTS_QTY_DECIMALS) -> bool:
|
||||
return normalize_contracts_qty(qty, decimals=decimals) > 0
|
||||
|
||||
|
||||
def position_contracts(p: dict[str, Any]) -> float:
|
||||
info = p.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
# OKX 等:info.pos 为交易所张数,优先于 ccxt contracts(加仓后后者可能滞后)
|
||||
for k in ("pos", "positionAmt", "positionamt", "size"):
|
||||
if k in info:
|
||||
try:
|
||||
v = float(info[k])
|
||||
if v != 0:
|
||||
return normalize_contracts_qty(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
raw = p.get("contracts")
|
||||
if raw is not None:
|
||||
try:
|
||||
v = float(raw)
|
||||
if v != 0:
|
||||
return normalize_contracts_qty(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
|
||||
def position_side_from_ccxt(p: dict[str, Any], contracts: float | None = None) -> str:
|
||||
s = (p.get("side") or "").lower()
|
||||
if s in ("long", "short"):
|
||||
return s
|
||||
c = contracts if contracts is not None else position_contracts(p)
|
||||
if c > 0:
|
||||
return "long"
|
||||
if c < 0:
|
||||
return "short"
|
||||
return "long"
|
||||
|
||||
|
||||
def parse_position_entry_price(p: dict[str, Any]) -> float | None:
|
||||
"""三所 ccxt 持仓开仓均价."""
|
||||
if not isinstance(p, dict):
|
||||
return None
|
||||
info = p.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
return _coerce_float(
|
||||
p.get("entryPrice"),
|
||||
p.get("entry_price"),
|
||||
p.get("average"),
|
||||
info.get("entryPrice"),
|
||||
info.get("entry_price"),
|
||||
info.get("avgPx"),
|
||||
info.get("avgEntryPrice"),
|
||||
info.get("avg_entry_price"),
|
||||
info.get("avgPrice"),
|
||||
info.get("openAvgPx"),
|
||||
)
|
||||
|
||||
|
||||
def estimate_linear_swap_upnl_usdt(
|
||||
side: str,
|
||||
entry: float | None,
|
||||
mark: float | None,
|
||||
contracts: float | None,
|
||||
contract_size: float | None = None,
|
||||
) -> float | None:
|
||||
"""U 本位线性永续:浮盈 = (标记价 - 开仓价) × 张数 × contractSize(空头取反)."""
|
||||
e = _finite_or_none(entry)
|
||||
m = _finite_or_none(mark)
|
||||
c = _finite_or_none(contracts)
|
||||
if e is None or m is None or c is None or c <= 0:
|
||||
return None
|
||||
mult = _finite_or_none(contract_size)
|
||||
if mult is None or mult <= 0:
|
||||
mult = 1.0
|
||||
diff = (m - e) if (side or "long").strip().lower() == "long" else (e - m)
|
||||
return round(diff * abs(c) * mult, 2)
|
||||
|
||||
|
||||
def resolve_position_display_upnl(
|
||||
side: str,
|
||||
entry: float | None,
|
||||
mark: float | None,
|
||||
contracts: float | None,
|
||||
contract_size: float | None,
|
||||
exchange_upnl: float | None,
|
||||
) -> float | None:
|
||||
"""展示用浮盈:优先与标记价/张数一致的推算;与交易所值偏差过大时用推算值."""
|
||||
computed = estimate_linear_swap_upnl_usdt(
|
||||
side, entry, mark, contracts, contract_size
|
||||
)
|
||||
if computed is None:
|
||||
return exchange_upnl
|
||||
if exchange_upnl is None:
|
||||
return computed
|
||||
ref = max(abs(computed), 1.0)
|
||||
if abs(exchange_upnl - computed) / ref > 0.2:
|
||||
return computed
|
||||
return exchange_upnl
|
||||
|
||||
|
||||
def _coerce_signed(*values: Any) -> float | None:
|
||||
"""解析可正可负的数值(未实现盈亏等)."""
|
||||
for v in values:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
f = _finite_or_none(v)
|
||||
if f is not None:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def parse_position_unrealized_pnl(p: dict[str, Any]) -> float | None:
|
||||
"""三所 ccxt 持仓统一解析未实现盈亏(Gate/OKX/Binance 字段名不一致)."""
|
||||
if not isinstance(p, dict):
|
||||
return None
|
||||
info = p.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
return _coerce_signed(
|
||||
p.get("unrealizedPnl"),
|
||||
p.get("unrealisedPnl"),
|
||||
p.get("unrealized_pnl"),
|
||||
p.get("unrealised_pnl"),
|
||||
info.get("unrealised_pnl"),
|
||||
info.get("unrealized_pnl"),
|
||||
info.get("unrealisedPnl"),
|
||||
info.get("unrealizedPnl"),
|
||||
info.get("upl"),
|
||||
info.get("uplLast"),
|
||||
)
|
||||
|
||||
|
||||
def enrich_ccxt_position_metrics_out(
|
||||
position: dict[str, Any],
|
||||
out: dict[str, Any],
|
||||
*,
|
||||
contract_size: float = 1.0,
|
||||
funds_decimals: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
三所 parse_ccxt_position_metrics 产出后统一:
|
||||
- 标记价用 hub 兜底
|
||||
- 未实现盈亏 = resolve(交易所值, entry/mark/张数/contractSize 推算)
|
||||
"""
|
||||
if not isinstance(position, dict) or not isinstance(out, dict):
|
||||
return out
|
||||
mark = _finite_or_none(out.get("mark_price"))
|
||||
if mark is None or mark <= 0:
|
||||
mp = parse_position_mark_price(position)
|
||||
if mp is not None and mp > 0:
|
||||
out["mark_price"] = round(mp, 8)
|
||||
mark = mp
|
||||
exchange_upnl = parse_position_unrealized_pnl(position)
|
||||
if exchange_upnl is None:
|
||||
exchange_upnl = _coerce_signed(out.get("unrealized_pnl"))
|
||||
c = position_contracts(position)
|
||||
if abs(c) < 1e-12:
|
||||
return out
|
||||
side = position_side_from_ccxt(position, c)
|
||||
entry = parse_position_entry_price(position)
|
||||
if entry is not None and entry > 0:
|
||||
out["entry_price"] = round(entry, 8)
|
||||
cs = contract_size if contract_size and contract_size > 0 else 1.0
|
||||
upnl = resolve_position_display_upnl(
|
||||
side, entry, mark, abs(c), cs, exchange_upnl
|
||||
)
|
||||
if upnl is not None:
|
||||
out["unrealized_pnl"] = round(upnl, funds_decimals)
|
||||
return out
|
||||
|
||||
|
||||
def parse_position_mark_price(p: dict[str, Any]) -> float | None:
|
||||
"""三所 ccxt 持仓统一解析标记价(与 crypto_monitor_* parse_ccxt_position_metrics 口径一致)."""
|
||||
if not isinstance(p, dict):
|
||||
return None
|
||||
info = p.get("info") or {}
|
||||
if not isinstance(info, dict):
|
||||
info = {}
|
||||
mark = _coerce_float(
|
||||
p.get("markPrice"),
|
||||
p.get("mark_price"),
|
||||
p.get("mark"),
|
||||
info.get("markPx"),
|
||||
info.get("mark_price"),
|
||||
info.get("markPrice"),
|
||||
)
|
||||
if mark is not None:
|
||||
return mark
|
||||
contracts = position_contracts(p)
|
||||
if abs(contracts) >= 1e-12:
|
||||
notional = _finite_or_none(p.get("notional"))
|
||||
if notional is not None and abs(notional) > 0:
|
||||
return abs(notional) / abs(contracts)
|
||||
return None
|
||||
|
||||
|
||||
def build_position_marks_list(
|
||||
positions: list,
|
||||
*,
|
||||
format_mark_display: Callable[[str, float], str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""从 fetch_positions 结果生成 position_marks,供 price_snapshot / 中控合并."""
|
||||
out: list[dict[str, Any]] = []
|
||||
for p in positions or []:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
c = position_contracts(p)
|
||||
if abs(c) < 1e-12:
|
||||
continue
|
||||
mark = parse_position_mark_price(p)
|
||||
if mark is None or mark <= 0:
|
||||
continue
|
||||
sym = (p.get("symbol") or "").strip()
|
||||
side = position_side_from_ccxt(p, c)
|
||||
row: dict[str, Any] = {
|
||||
"symbol": sym,
|
||||
"side": side,
|
||||
"mark_price": mark,
|
||||
}
|
||||
if format_mark_display and sym:
|
||||
try:
|
||||
row["mark_price_display"] = format_mark_display(sym, mark)
|
||||
except Exception:
|
||||
row["mark_price_display"] = f"{mark:g}"
|
||||
else:
|
||||
row["mark_price_display"] = f"{mark:g}"
|
||||
out.append(row)
|
||||
return out
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Hub 中控市价全平后立即同步 order_monitors(三所共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def reconcile_hub_external_close_impl(
|
||||
conn,
|
||||
symbol: str,
|
||||
direction: str,
|
||||
*,
|
||||
exchange_configured: Callable[[], bool],
|
||||
not_configured_msg: str,
|
||||
symbols_match: Callable[[str, str], bool],
|
||||
get_opened_at_value: Callable[[Any], str],
|
||||
resolve_monitor_exchange_symbol: Callable[[Any], str],
|
||||
get_live_position_contracts: Callable[[str, str], float | None],
|
||||
cancel_conditional_orders: Callable[[str], None],
|
||||
resolve_synced_flat_close: Callable[..., tuple],
|
||||
finalize_stopped_monitor: Callable[..., None],
|
||||
sync_trade_records: Callable[..., None] | None = None,
|
||||
reconcile_flat_streak: dict | None = None,
|
||||
to_ms_with_fallback: Callable[..., int | None] | None = None,
|
||||
prefer_manual_resolve: bool = False,
|
||||
order_row_monitor_type: Callable[[Any], str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not exchange_configured():
|
||||
return {"ok": False, "msg": not_configured_msg, "synced": 0}
|
||||
sym_req = (symbol or "").strip()
|
||||
dir_l = (direction or "").strip().lower()
|
||||
if dir_l not in ("long", "short"):
|
||||
return {"ok": False, "msg": "side 须为 long 或 short", "synced": 0}
|
||||
synced = 0
|
||||
streak = reconcile_flat_streak if reconcile_flat_streak is not None else {}
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM order_monitors WHERE status IN ('active', 'error')"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if not symbols_match(str(r["symbol"] or ""), sym_req):
|
||||
continue
|
||||
if (r["direction"] or "").strip().lower() != dir_l:
|
||||
continue
|
||||
oid = int(r["id"])
|
||||
if r["status"] == "error":
|
||||
opened_at_chk = get_opened_at_value(r)
|
||||
mtype = order_row_monitor_type(r) if order_row_monitor_type else r["monitor_type"]
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM trade_records WHERE symbol=? AND opened_at=? AND monitor_type=? LIMIT 1",
|
||||
(r["symbol"], opened_at_chk, mtype),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (oid,))
|
||||
synced += 1
|
||||
continue
|
||||
exchange_symbol = resolve_monitor_exchange_symbol(r)
|
||||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||||
if live_contracts is None:
|
||||
continue
|
||||
if live_contracts > 0:
|
||||
time.sleep(0.6)
|
||||
live_contracts = get_live_position_contracts(exchange_symbol, r["direction"])
|
||||
if live_contracts is None or live_contracts > 0:
|
||||
continue
|
||||
streak.pop(oid, None)
|
||||
cancel_conditional_orders(exchange_symbol)
|
||||
opened_at = get_opened_at_value(r)
|
||||
opened_at_ms = None
|
||||
if to_ms_with_fallback is not None:
|
||||
keys = r.keys() if hasattr(r, "keys") else ()
|
||||
opened_at_ms = to_ms_with_fallback(
|
||||
r["opened_at_ms"] if "opened_at_ms" in keys else None,
|
||||
opened_at,
|
||||
)
|
||||
resolve_kw = {"opened_at_ms": opened_at_ms}
|
||||
if prefer_manual_resolve:
|
||||
resolve_kw["prefer_manual"] = True
|
||||
result, pnl_amount, closed_at, miss_reason = resolve_synced_flat_close(
|
||||
r, opened_at, **resolve_kw
|
||||
)
|
||||
finalize_stopped_monitor(
|
||||
conn,
|
||||
r,
|
||||
result=result,
|
||||
pnl_amount=pnl_amount,
|
||||
closed_at=closed_at,
|
||||
miss_reason=miss_reason,
|
||||
)
|
||||
synced += 1
|
||||
if sync_trade_records is not None:
|
||||
try:
|
||||
sync_trade_records(conn, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "synced": synced}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN).无 Flask 依赖,供中控 FastAPI 与各实例共用.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
HUB_SSO_TTL_SEC = int(os.getenv("HUB_SSO_TTL_SEC", "7200"))
|
||||
HUB_EMBED_BOOTSTRAP_TTL_SEC = int(os.getenv("HUB_EMBED_BOOTSTRAP_TTL_SEC", "120"))
|
||||
|
||||
_used_nonces: dict[str, float] = {}
|
||||
_nonce_lock = threading.Lock()
|
||||
|
||||
|
||||
def hub_bridge_token() -> str:
|
||||
return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
|
||||
|
||||
|
||||
def safe_next_path(raw: str | None) -> str:
|
||||
p = (raw or "/").strip()
|
||||
if not p.startswith("/") or p.startswith("//"):
|
||||
return "/"
|
||||
if "://" in p:
|
||||
return "/"
|
||||
return p
|
||||
|
||||
|
||||
def _sso_secret() -> str:
|
||||
return hub_bridge_token()
|
||||
|
||||
|
||||
def _b64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
||||
|
||||
|
||||
def _b64url_decode(data: str) -> bytes:
|
||||
pad = "=" * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(data + pad)
|
||||
|
||||
|
||||
def _prune_used_nonces() -> None:
|
||||
now = time.time()
|
||||
with _nonce_lock:
|
||||
dead = [k for k, exp in _used_nonces.items() if exp <= now]
|
||||
for k in dead:
|
||||
del _used_nonces[k]
|
||||
|
||||
|
||||
def mint_hub_sso_token(exchange_key: str, next_path: str = "/") -> str | None:
|
||||
secret = _sso_secret()
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if not secret or not ex:
|
||||
return None
|
||||
payload = {
|
||||
"ex": ex,
|
||||
"exp": int(time.time()) + max(60, HUB_SSO_TTL_SEC),
|
||||
"nonce": secrets.token_urlsafe(16),
|
||||
"next": safe_next_path(next_path),
|
||||
}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{body}.{sig}"
|
||||
|
||||
|
||||
def verify_hub_sso_token(
|
||||
token: str | None, expected_exchange: str
|
||||
) -> tuple[bool, str, str | None]:
|
||||
secret = _sso_secret()
|
||||
expected = (expected_exchange or "").strip().lower()
|
||||
if not secret or not expected:
|
||||
return False, "/", "未配置 HUB_BRIDGE_TOKEN"
|
||||
raw = (token or "").strip()
|
||||
if "." not in raw:
|
||||
return False, "/", "token 无效"
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
try:
|
||||
expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expect_sig, sig):
|
||||
return False, "/", "签名校验失败"
|
||||
payload = json.loads(_b64url_decode(body).decode())
|
||||
except Exception:
|
||||
return False, "/", "token 解析失败"
|
||||
if not isinstance(payload, dict):
|
||||
return False, "/", "payload 无效"
|
||||
if str(payload.get("ex") or "").lower() != expected:
|
||||
return False, "/", "实例不匹配"
|
||||
try:
|
||||
exp = int(payload.get("exp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False, "/", "exp 无效"
|
||||
if exp < int(time.time()):
|
||||
return False, "/", "链接已过期"
|
||||
nonce = str(payload.get("nonce") or "")
|
||||
if not nonce:
|
||||
return False, "/", "nonce 缺失"
|
||||
_prune_used_nonces()
|
||||
with _nonce_lock:
|
||||
if nonce in _used_nonces:
|
||||
return False, "/", "链接已使用"
|
||||
_used_nonces[nonce] = float(exp)
|
||||
return True, safe_next_path(str(payload.get("next") or "/")), None
|
||||
|
||||
|
||||
def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None:
|
||||
"""iframe 内嵌登录引导 token(短效,单次),供 /hub-embed-auth 写入 SameSite=None Cookie."""
|
||||
secret = _sso_secret()
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if not secret or not ex:
|
||||
return None
|
||||
payload = {
|
||||
"kind": "embed",
|
||||
"ex": ex,
|
||||
"exp": int(time.time()) + max(30, HUB_EMBED_BOOTSTRAP_TTL_SEC),
|
||||
"nonce": secrets.token_urlsafe(16),
|
||||
"next": safe_next_path(next_path),
|
||||
}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode())
|
||||
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{body}.{sig}"
|
||||
|
||||
|
||||
def verify_hub_embed_bootstrap(
|
||||
token: str | None, expected_exchange: str
|
||||
) -> tuple[bool, str, str | None]:
|
||||
secret = _sso_secret()
|
||||
expected = (expected_exchange or "").strip().lower()
|
||||
if not secret or not expected:
|
||||
return False, "/", "未配置 HUB_BRIDGE_TOKEN"
|
||||
raw = (token or "").strip()
|
||||
if "." not in raw:
|
||||
return False, "/", "token 无效"
|
||||
body, sig = raw.rsplit(".", 1)
|
||||
try:
|
||||
expect_sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expect_sig, sig):
|
||||
return False, "/", "签名校验失败"
|
||||
payload = json.loads(_b64url_decode(body).decode())
|
||||
except Exception:
|
||||
return False, "/", "token 解析失败"
|
||||
if not isinstance(payload, dict) or payload.get("kind") != "embed":
|
||||
return False, "/", "token 类型无效"
|
||||
if str(payload.get("ex") or "").lower() != expected:
|
||||
return False, "/", "实例不匹配"
|
||||
try:
|
||||
exp = int(payload.get("exp") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return False, "/", "exp 无效"
|
||||
if exp < int(time.time()):
|
||||
return False, "/", "链接已过期"
|
||||
nonce = str(payload.get("nonce") or "")
|
||||
if not nonce:
|
||||
return False, "/", "nonce 缺失"
|
||||
key = f"embed:{nonce}"
|
||||
_prune_used_nonces()
|
||||
with _nonce_lock:
|
||||
if key in _used_nonces:
|
||||
return False, "/", "链接已使用"
|
||||
_used_nonces[key] = float(exp)
|
||||
return True, safe_next_path(str(payload.get("next") or "/")), None
|
||||
@@ -0,0 +1,542 @@
|
||||
"""中控「策略说明」:读取 docs/strategy MD + checklists JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lib.paths import REPO_ROOT
|
||||
|
||||
STRATEGY_EXCHANGES: tuple[str, ...] = (
|
||||
"playbook_v2",
|
||||
"playbook",
|
||||
"behavior",
|
||||
"binance",
|
||||
"okx",
|
||||
"gate",
|
||||
)
|
||||
|
||||
STRATEGY_META: dict[str, dict[str, str]] = {
|
||||
"playbook_v2": {
|
||||
"label": "执行手册v2",
|
||||
"title": "交易执行手册 v2(期权 / 合约 · 无对冲)",
|
||||
"md_rel": "docs/交易执行手册-v2-期权与合约.md",
|
||||
},
|
||||
"playbook": {
|
||||
"label": "执行手册v1",
|
||||
"title": "交易执行手册 v1(期权为主 · Gate 为辅 · 含对冲)",
|
||||
# 相对仓库根;其余条目用 md_file 相对 docs/strategy
|
||||
"md_rel": "docs/交易执行手册-期权与Gate.md",
|
||||
},
|
||||
"behavior": {
|
||||
"label": "行为准则",
|
||||
"title": "交易行为准则(开单三检)",
|
||||
"md_rel": "docs/交易行为准则-开单三检.md",
|
||||
},
|
||||
"binance": {
|
||||
"label": "币安",
|
||||
"title": "币安·山寨多头趋势",
|
||||
"md_file": "binance-alt-trend-long.md",
|
||||
},
|
||||
"okx": {
|
||||
"label": "OKX",
|
||||
"title": "OKX·多空趋势",
|
||||
"md_file": "okx-trend-both.md",
|
||||
},
|
||||
"gate": {
|
||||
"label": "Gate",
|
||||
"title": "Gate·BTC 日内",
|
||||
"md_file": "gate-intraday.md",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _strategy_dir() -> Path:
|
||||
return REPO_ROOT / "docs" / "strategy"
|
||||
|
||||
|
||||
def _checklist_path(exchange_key: str) -> Path:
|
||||
return _strategy_dir() / "checklists" / f"{exchange_key.strip().lower()}.json"
|
||||
|
||||
|
||||
def _md_path(exchange_key: str) -> Path:
|
||||
meta = STRATEGY_META.get((exchange_key or "").strip().lower())
|
||||
if not meta:
|
||||
raise KeyError(exchange_key)
|
||||
md_rel = (meta.get("md_rel") or "").strip()
|
||||
if md_rel:
|
||||
return REPO_ROOT / md_rel
|
||||
return _strategy_dir() / meta["md_file"]
|
||||
|
||||
|
||||
def _parse_version(md_text: str) -> str:
|
||||
m = re.search(r">\s*\*\*状态\*\*[::]\s*(v[\d.]+)", md_text)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"\|\s*v([\d.]+)\s*\|", md_text)
|
||||
if m:
|
||||
return f"v{m.group(1)}"
|
||||
return ""
|
||||
|
||||
|
||||
def render_markdown_html(md_text: str) -> str:
|
||||
try:
|
||||
import markdown # type: ignore
|
||||
|
||||
return markdown.markdown(
|
||||
md_text,
|
||||
extensions=["tables", "fenced_code", "nl2br", "sane_lists"],
|
||||
)
|
||||
except Exception:
|
||||
return _simple_md_html(md_text)
|
||||
|
||||
|
||||
def _simple_md_html(md_text: str) -> str:
|
||||
from html import escape
|
||||
|
||||
lines = md_text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
in_code = False
|
||||
code_buf: list[str] = []
|
||||
list_buf: list[str] = []
|
||||
list_ordered = False
|
||||
|
||||
def flush_list() -> None:
|
||||
nonlocal list_buf, list_ordered
|
||||
if not list_buf:
|
||||
return
|
||||
tag = "ol" if list_ordered else "ul"
|
||||
out.append(f"<{tag}>")
|
||||
for item in list_buf:
|
||||
out.append(f"<li>{_inline_md(item)}</li>")
|
||||
out.append(f"</{tag}>")
|
||||
list_buf = []
|
||||
|
||||
def flush_code() -> None:
|
||||
nonlocal code_buf, in_code
|
||||
if not code_buf:
|
||||
return
|
||||
out.append(f"<pre><code>{escape(chr(10).join(code_buf))}</code></pre>")
|
||||
code_buf = []
|
||||
in_code = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.strip().startswith("```"):
|
||||
flush_list()
|
||||
if in_code:
|
||||
flush_code()
|
||||
else:
|
||||
in_code = True
|
||||
i += 1
|
||||
continue
|
||||
if in_code:
|
||||
code_buf.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*\|", line) and i + 1 < len(lines) and re.match(r"^\s*\|?\s*[-:| ]+\|", lines[i + 1]):
|
||||
flush_list()
|
||||
header = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||
i += 2
|
||||
rows: list[list[str]] = []
|
||||
while i < len(lines) and re.match(r"^\s*\|", lines[i]):
|
||||
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
||||
i += 1
|
||||
out.append("<table><thead><tr>" + "".join(f"<th>{_inline_md(h)}</th>" for h in header) + "</tr></thead><tbody>")
|
||||
for row in rows:
|
||||
out.append("<tr>" + "".join(f"<td>{_inline_md(c)}</td>" for c in row) + "</tr>")
|
||||
out.append("</tbody></table>")
|
||||
continue
|
||||
if re.match(r"^#{1,3}\s+", line):
|
||||
flush_list()
|
||||
m = re.match(r"^(#{1,3})\s+(.*)$", line)
|
||||
if m:
|
||||
level = len(m.group(1))
|
||||
out.append(f"<h{level}>{_inline_md(m.group(2))}</h{level}>")
|
||||
i += 1
|
||||
continue
|
||||
if line.strip() == "---":
|
||||
flush_list()
|
||||
out.append("<hr>")
|
||||
i += 1
|
||||
continue
|
||||
if line.startswith(">"):
|
||||
flush_list()
|
||||
out.append(f"<blockquote>{_inline_md(line.lstrip('>').strip())}</blockquote>")
|
||||
i += 1
|
||||
continue
|
||||
m = re.match(r"^(\d+)\.\s+(.*)$", line.strip())
|
||||
if m:
|
||||
if list_buf and not list_ordered:
|
||||
flush_list()
|
||||
list_ordered = True
|
||||
list_buf.append(m.group(2))
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^[-*]\s+", line.strip()):
|
||||
if list_buf and list_ordered:
|
||||
flush_list()
|
||||
list_ordered = False
|
||||
list_buf.append(re.sub(r"^[-*]\s+", "", line.strip()))
|
||||
i += 1
|
||||
continue
|
||||
if not line.strip():
|
||||
flush_list()
|
||||
i += 1
|
||||
continue
|
||||
flush_list()
|
||||
out.append(f"<p>{_inline_md(line.strip())}</p>")
|
||||
i += 1
|
||||
flush_list()
|
||||
flush_code()
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _inline_md(text: str) -> str:
|
||||
from html import escape
|
||||
|
||||
s = escape(text)
|
||||
s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
|
||||
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
|
||||
return s
|
||||
|
||||
|
||||
def load_checklist(exchange_key: str) -> dict[str, Any]:
|
||||
path = _checklist_path(exchange_key)
|
||||
if not path.is_file():
|
||||
return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []}
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return {"exchange": exchange_key, "title": "开仓检查清单", "groups": []}
|
||||
return data
|
||||
|
||||
|
||||
def load_strategy_payload(exchange_key: str) -> dict[str, Any]:
|
||||
key = (exchange_key or "").strip().lower()
|
||||
if key not in STRATEGY_META:
|
||||
raise KeyError(exchange_key)
|
||||
meta = STRATEGY_META[key]
|
||||
md_path = _md_path(key)
|
||||
md_text = md_path.read_text(encoding="utf-8") if md_path.is_file() else ""
|
||||
checklist = load_checklist(key)
|
||||
version = _parse_version(md_text) or str(checklist.get("version") or "")
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange_key": key,
|
||||
"label": meta["label"],
|
||||
"title": meta["title"],
|
||||
"version": version,
|
||||
"md_source": str(md_path.relative_to(REPO_ROOT)).replace("\\", "/"),
|
||||
"strategy_html": render_markdown_html(md_text),
|
||||
"checklist": checklist,
|
||||
}
|
||||
|
||||
|
||||
_STRATEGY_TAB_DISPLAY_PREF: dict[str, str] = {
|
||||
"playbook_v2": "show_strategy_playbook_v2",
|
||||
"playbook": "show_strategy_playbook",
|
||||
"behavior": "show_strategy_behavior",
|
||||
"binance": "show_strategy_binance",
|
||||
"okx": "show_strategy_okx",
|
||||
"gate": "show_strategy_gate",
|
||||
}
|
||||
|
||||
|
||||
def strategy_meta_payload(display: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
prefs = display if isinstance(display, dict) else {}
|
||||
tabs = []
|
||||
for k in STRATEGY_EXCHANGES:
|
||||
pref_key = _STRATEGY_TAB_DISPLAY_PREF.get(k)
|
||||
if pref_key and prefs.get(pref_key) is False:
|
||||
continue
|
||||
tabs.append(
|
||||
{
|
||||
"key": k,
|
||||
"label": STRATEGY_META[k]["label"],
|
||||
"title": STRATEGY_META[k]["title"],
|
||||
}
|
||||
)
|
||||
return {"ok": True, "exchanges": tabs}
|
||||
|
||||
|
||||
def _checklist_html(checklist: dict[str, Any], *, include_title: bool = True) -> str:
|
||||
groups = checklist.get("groups") or []
|
||||
parts: list[str] = []
|
||||
if include_title:
|
||||
parts.append(f"<h2>{escape_html(str(checklist.get('title') or '开仓检查清单'))}</h2>")
|
||||
for grp in groups:
|
||||
if not isinstance(grp, dict):
|
||||
continue
|
||||
gtitle = escape_html(str(grp.get("title") or ""))
|
||||
parts.append(f"<h3>{gtitle}</h3><ul class=\"checklist\">")
|
||||
for item in grp.get("items") or []:
|
||||
# 用 CSS 方框代替 ☐,避免部分打印机/预览吞掉 Unicode 勾选符
|
||||
parts.append(
|
||||
f"<li><span class=\"box\" aria-hidden=\"true\"></span>"
|
||||
f"<span class=\"item-text\">{escape_html(str(item))}</span></li>"
|
||||
)
|
||||
parts.append("</ul>")
|
||||
footnotes = checklist.get("footnotes") or []
|
||||
if footnotes:
|
||||
parts.append("<ul class=\"footnotes\">")
|
||||
for note in footnotes:
|
||||
parts.append(f"<li>{escape_html(str(note))}</li>")
|
||||
parts.append("</ul>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _print_document_css() -> str:
|
||||
return """
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 36px 28px 48px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
.doc {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.doc-head {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
.doc-head h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.doc-meta {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.doc-body h2 {
|
||||
font-size: 1.12rem;
|
||||
margin: 1.6em 0 0.55em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-body h3 {
|
||||
font-size: 1rem;
|
||||
margin: 1.2em 0 0.45em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-body h2:first-child,
|
||||
.doc-body h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.doc-body p {
|
||||
margin: 0.65em 0;
|
||||
}
|
||||
.doc-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.92rem;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
.doc-body th,
|
||||
.doc-body td {
|
||||
border: 1px solid #d8d8d8;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.doc-body blockquote {
|
||||
margin: 12px 0;
|
||||
padding: 8px 14px;
|
||||
border-left: 4px solid #c8c8c8;
|
||||
color: #444;
|
||||
background: #fafafa;
|
||||
}
|
||||
.doc-body pre,
|
||||
.doc-body code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.doc-body pre {
|
||||
padding: 10px 12px;
|
||||
background: #f6f6f6;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.doc-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
margin: 1.4em 0;
|
||||
}
|
||||
.checklist {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
}
|
||||
.checklist li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.box {
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
width: 0.95em;
|
||||
height: 0.95em;
|
||||
margin-top: 0.2em;
|
||||
border: 1.5px solid #333;
|
||||
border-radius: 2px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.item-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.footnotes {
|
||||
margin: 18px 0 0;
|
||||
padding-left: 20px;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.doc-foot {
|
||||
margin-top: 28px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #ddd;
|
||||
color: #888;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
@media print {
|
||||
body { padding: 0; }
|
||||
.doc { max-width: none; }
|
||||
.doc-head { break-after: avoid; }
|
||||
.doc-body h2, .doc-body h3 { break-after: avoid; }
|
||||
.checklist li { break-inside: avoid; }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _print_auto_script() -> str:
|
||||
# 不在 afterprint 里 window.close(): Firefox 会在打开打印框时就触发 afterprint,
|
||||
# 短页(检查清单)更容易被立刻关掉,表现为「打不开/打不出来」;策略长文相对不易复现.
|
||||
return """<script>
|
||||
(function () {
|
||||
function go() {
|
||||
try { window.focus(); } catch (e) {}
|
||||
window.setTimeout(function () {
|
||||
try { window.print(); } catch (e) {}
|
||||
}, 250);
|
||||
}
|
||||
if (document.readyState === "complete") go();
|
||||
else window.addEventListener("load", go, { once: true });
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
|
||||
def build_print_html(exchange_key: str, part: str = "doc") -> str:
|
||||
"""part: doc | checklist"""
|
||||
payload = load_strategy_payload(exchange_key)
|
||||
key = payload["exchange_key"]
|
||||
label = payload["label"]
|
||||
title = payload["title"]
|
||||
version = payload.get("version") or ""
|
||||
checklist = payload.get("checklist") or {}
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
part = (part or "doc").strip().lower()
|
||||
css = _print_document_css()
|
||||
|
||||
if part == "checklist":
|
||||
cl_title = str(checklist.get("title") or "开仓检查清单")
|
||||
page_title = f"{label} · {cl_title}"
|
||||
body = f"""<article class="doc">
|
||||
<header class="doc-head">
|
||||
<h1>{escape_html(cl_title)}</h1>
|
||||
<p class="doc-meta">{escape_html(label)} · {escape_html(version)} · 打印 {escape_html(now)}</p>
|
||||
</header>
|
||||
<div class="doc-body">{_checklist_html(checklist, include_title=False)}</div>
|
||||
</article>"""
|
||||
elif part == "doc":
|
||||
page_title = f"{label} · 策略说明"
|
||||
source = payload.get("md_source") or ""
|
||||
body = f"""<article class="doc">
|
||||
<header class="doc-head">
|
||||
<h1>{escape_html(title)}</h1>
|
||||
<p class="doc-meta">{escape_html(label)} · {escape_html(version)} · 打印 {escape_html(now)}</p>
|
||||
</header>
|
||||
<div class="doc-body">{payload.get("strategy_html") or ""}</div>
|
||||
<footer class="doc-foot">文档:{escape_html(str(source))}{(" · " + escape_html(version)) if version else ""}</footer>
|
||||
</article>"""
|
||||
else:
|
||||
raise KeyError(part)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{escape_html(page_title)}</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
{_print_auto_script()}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def build_export_html(exchange_key: str) -> str:
|
||||
payload = load_strategy_payload(exchange_key)
|
||||
key = payload["exchange_key"]
|
||||
label = payload["label"]
|
||||
title = payload["title"]
|
||||
version = payload.get("version") or ""
|
||||
checklist = payload.get("checklist") or {}
|
||||
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
checklist_html = _checklist_html(checklist)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{escape_html(label)} · 策略说明</title>
|
||||
<style>
|
||||
body{{font-family:system-ui,sans-serif;margin:24px;color:#111;line-height:1.55}}
|
||||
h1{{font-size:1.35rem;margin:0 0 8px}}
|
||||
.meta{{color:#555;font-size:.85rem;margin-bottom:20px}}
|
||||
.grid{{display:grid;grid-template-columns:1fr 1fr;gap:24px;align-items:start}}
|
||||
.strategy-doc h2{{font-size:1.05rem;margin-top:1.2em}}
|
||||
.strategy-doc table{{border-collapse:collapse;width:100%;font-size:.88rem}}
|
||||
.strategy-doc th,.strategy-doc td{{border:1px solid #ccc;padding:6px 8px;text-align:left}}
|
||||
.checklist ul{{list-style:none;padding:0;margin:0}}
|
||||
.checklist li{{display:flex;align-items:flex-start;gap:8px;margin:8px 0}}
|
||||
.box{{flex:0 0 auto;display:inline-block;width:.95em;height:.95em;margin-top:.2em;border:1.5px solid #333;border-radius:2px;background:#fff;box-sizing:border-box}}
|
||||
.item-text{{flex:1 1 auto}}
|
||||
@media print{{body{{margin:12mm}}}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{escape_html(title)}</h1>
|
||||
<div class="meta">{escape_html(label)} · {escape_html(version)} · 导出 {now}</div>
|
||||
<div class="grid">
|
||||
<div class="strategy-doc">{payload.get("strategy_html") or ""}</div>
|
||||
<div class="checklist">{checklist_html}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def escape_html(text: str) -> str:
|
||||
from html import escape
|
||||
|
||||
return escape(text, quote=True)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
"""合约 symbol 匹配(持仓 vs 监控/挂单)."""
|
||||
|
||||
|
||||
def _symbol_base_coin(symbol: str) -> str:
|
||||
s = (symbol or "").strip().upper()
|
||||
if not s:
|
||||
return ""
|
||||
if "-SWAP" in s:
|
||||
s = s.replace("-SWAP", "")
|
||||
if "-" in s:
|
||||
return s.split("-", 1)[0]
|
||||
if "/" in s:
|
||||
return s.split("/", 1)[0]
|
||||
if ":" in s:
|
||||
return s.split(":", 1)[0]
|
||||
return s
|
||||
|
||||
|
||||
def symbols_match(position_symbol: str, order_symbol: str) -> bool:
|
||||
a = (position_symbol or "").strip().upper()
|
||||
b = (order_symbol or "").strip().upper()
|
||||
if not a or not b:
|
||||
return False
|
||||
if a == b:
|
||||
return True
|
||||
ba, bb = _symbol_base_coin(a), _symbol_base_coin(b)
|
||||
if ba and bb and ba == bb:
|
||||
return True
|
||||
for suf in (":USDT", "/USDT:USDT", "/USDT"):
|
||||
a2 = a.replace(suf, "")
|
||||
b2 = b.replace(suf, "")
|
||||
if f"{a2}/USDT" == b or f"{a2}/USDT:USDT" == b:
|
||||
return True
|
||||
if f"{b2}/USDT" == a or f"{b2}/USDT:USDT" == a:
|
||||
return True
|
||||
if a2 == b2:
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,172 @@
|
||||
"""中控:读取 PM2 进程 stdout/stderr 日志(系统日志页)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
LOG_TARGETS: dict[str, dict[str, str]] = {
|
||||
"binance": {"label": "币安", "pm2_name": "crypto_binance"},
|
||||
"gate": {"label": "Gate", "pm2_name": "crypto_gate"},
|
||||
"okx": {"label": "OKX", "pm2_name": "crypto_okx"},
|
||||
"hub": {"label": "中控", "pm2_name": "manual-trading-hub"},
|
||||
}
|
||||
|
||||
DEFAULT_LINES = 200
|
||||
MAX_LINES = 500
|
||||
DEFAULT_TAIL_BYTES = 400_000
|
||||
_PATH_CACHE_TTL_SEC = 30
|
||||
|
||||
_path_cache: dict[str, tuple[Path, Path]] = {}
|
||||
_path_cache_at = 0.0
|
||||
|
||||
|
||||
def pm2_logs_dir() -> Path:
|
||||
raw = (os.getenv("PM2_HOME") or "").strip()
|
||||
base = Path(raw) if raw else Path.home() / ".pm2"
|
||||
return base / "logs"
|
||||
|
||||
|
||||
def _pm2_jlist() -> list[dict[str, Any]]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pm2", "jlist"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=8,
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
data = json.loads(proc.stdout or "[]")
|
||||
return data if isinstance(data, list) else []
|
||||
except (OSError, subprocess.SubprocessError, json.JSONDecodeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _newest_match(pattern: str, logs_dir: Path) -> Path | None:
|
||||
matches = [p for p in logs_dir.glob(pattern) if p.is_file()]
|
||||
if not matches:
|
||||
return None
|
||||
return max(matches, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def _glob_log_paths(pm2_name: str) -> tuple[Path, Path]:
|
||||
logs_dir = pm2_logs_dir()
|
||||
out_path = Path()
|
||||
err_path = Path()
|
||||
if not logs_dir.is_dir():
|
||||
return out_path, err_path
|
||||
slugs = {pm2_name, pm2_name.replace("_", "-")}
|
||||
for slug in slugs:
|
||||
if not out_path.is_file():
|
||||
hit = _newest_match(f"{slug}-out*.log", logs_dir)
|
||||
if hit is not None:
|
||||
out_path = hit
|
||||
if not err_path.is_file():
|
||||
hit = _newest_match(f"{slug}-error*.log", logs_dir)
|
||||
if hit is not None:
|
||||
err_path = hit
|
||||
return out_path, err_path
|
||||
|
||||
|
||||
def resolve_log_paths(pm2_name: str) -> tuple[Path, Path]:
|
||||
global _path_cache_at
|
||||
now = time.time()
|
||||
if now - _path_cache_at > _PATH_CACHE_TTL_SEC:
|
||||
_path_cache.clear()
|
||||
_path_cache_at = now
|
||||
cached = _path_cache.get(pm2_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
out_path = Path()
|
||||
err_path = Path()
|
||||
for proc in _pm2_jlist():
|
||||
if proc.get("name") != pm2_name:
|
||||
continue
|
||||
env = proc.get("pm2_env") or {}
|
||||
raw_out = (env.get("pm_out_log_path") or "").strip()
|
||||
raw_err = (env.get("pm_err_log_path") or "").strip()
|
||||
if raw_out:
|
||||
out_path = Path(raw_out)
|
||||
if raw_err:
|
||||
err_path = Path(raw_err)
|
||||
break
|
||||
|
||||
glob_out, glob_err = _glob_log_paths(pm2_name)
|
||||
if not out_path.is_file() and glob_out.is_file():
|
||||
out_path = glob_out
|
||||
if not err_path.is_file() and glob_err.is_file():
|
||||
err_path = glob_err
|
||||
|
||||
result = (out_path, err_path)
|
||||
_path_cache[pm2_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def log_file_paths(pm2_name: str) -> tuple[Path, Path]:
|
||||
return resolve_log_paths(pm2_name)
|
||||
|
||||
|
||||
def tail_lines(
|
||||
path: Path,
|
||||
lines: int = DEFAULT_LINES,
|
||||
*,
|
||||
max_bytes: int = DEFAULT_TAIL_BYTES,
|
||||
) -> str:
|
||||
if not path.is_file():
|
||||
return ""
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with path.open("rb") as handle:
|
||||
if size <= max_bytes:
|
||||
data = handle.read()
|
||||
else:
|
||||
handle.seek(max(0, size - max_bytes))
|
||||
data = handle.read()
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
parts = text.splitlines()
|
||||
if len(parts) > lines:
|
||||
parts = parts[-lines:]
|
||||
return "\n".join(parts)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def system_logs_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"targets": [
|
||||
{"key": key, "label": cfg["label"], "pm2_name": cfg["pm2_name"]}
|
||||
for key, cfg in LOG_TARGETS.items()
|
||||
],
|
||||
"default_lines": DEFAULT_LINES,
|
||||
"max_lines": MAX_LINES,
|
||||
}
|
||||
|
||||
|
||||
def load_system_logs(target: str, lines: int = DEFAULT_LINES) -> dict[str, Any]:
|
||||
key = (target or "").strip().lower()
|
||||
if key not in LOG_TARGETS:
|
||||
raise KeyError(key)
|
||||
cfg = LOG_TARGETS[key]
|
||||
line_count = max(20, min(MAX_LINES, int(lines or DEFAULT_LINES)))
|
||||
out_path, err_path = resolve_log_paths(cfg["pm2_name"])
|
||||
return {
|
||||
"ok": True,
|
||||
"key": key,
|
||||
"label": cfg["label"],
|
||||
"pm2_name": cfg["pm2_name"],
|
||||
"lines": line_count,
|
||||
"out": tail_lines(out_path, line_count),
|
||||
"err": tail_lines(err_path, line_count),
|
||||
"out_exists": out_path.is_file(),
|
||||
"err_exists": err_path.is_file(),
|
||||
"out_path": str(out_path) if str(out_path) else "",
|
||||
"err_path": str(err_path) if str(err_path) else "",
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
"""各实例当日平仓记录查询(供 hub_bridge /api/hub/trades/today 与中控 AI 聚合)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from lib.strategy.strategy_trade_labels import (
|
||||
MONITOR_TYPE_ROLL,
|
||||
MONITOR_TYPE_TREND_PULLBACK,
|
||||
entry_reason_for_monitor_type,
|
||||
)
|
||||
from lib.trade.time_close_lib import TIME_CLOSE_RESULT
|
||||
from lib.trade.entry_model_lib import format_entry_type_display
|
||||
|
||||
TRADE_COMPLETED_RESULTS = (
|
||||
"止盈",
|
||||
"止损",
|
||||
"保本止盈",
|
||||
"移动止盈",
|
||||
"手动平仓",
|
||||
"强制清仓",
|
||||
"外部平仓",
|
||||
TIME_CLOSE_RESULT,
|
||||
)
|
||||
|
||||
|
||||
def trading_day_from_dt(dt: datetime, reset_hour: int = 8) -> str:
|
||||
"""与实例 get_trading_day 一致:小时 < reset_hour 归属上一日历日."""
|
||||
if dt.hour < reset_hour:
|
||||
dt = dt - timedelta(days=1)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def current_trading_day(*, now: datetime | None = None, reset_hour: int = 8) -> str:
|
||||
return trading_day_from_dt(now or datetime.now(), reset_hour)
|
||||
|
||||
|
||||
def parse_dt_for_trading_day(raw: Any) -> datetime | None:
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip().replace("Z", "").replace("T", " ")
|
||||
if not s:
|
||||
return None
|
||||
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16), ("%Y-%m-%d", 10)):
|
||||
try:
|
||||
return datetime.strptime(s[:ln], fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def trading_day_window_bounds(trading_day: str, reset_hour: int = 8) -> tuple[str, str]:
|
||||
"""交易日 [reset_hour, 次日 reset_hour) 对应的北京时间字符串区间(闭区间)."""
|
||||
day = datetime.strptime((trading_day or "").strip()[:10], "%Y-%m-%d")
|
||||
start = day.replace(hour=reset_hour, minute=0, second=0, microsecond=0)
|
||||
end = start + timedelta(days=1) - timedelta(seconds=1)
|
||||
return start.strftime("%Y-%m-%d %H:%M:%S"), end.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _row_dict(row, row_to_dict: Optional[Callable] = None) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
if row_to_dict:
|
||||
try:
|
||||
return dict(row_to_dict(row))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
keys = row.keys() if hasattr(row, "keys") else ()
|
||||
if keys:
|
||||
return {k: row[k] for k in keys}
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return dict(row)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _effective_field(d: dict, reviewed_key: str, base_key: str, default: Any = None) -> Any:
|
||||
rv = d.get(reviewed_key)
|
||||
if rv is not None and str(rv).strip() != "":
|
||||
return rv
|
||||
bv = d.get(base_key)
|
||||
if bv is not None and str(bv).strip() != "":
|
||||
return bv
|
||||
return default
|
||||
|
||||
|
||||
def format_hold_minutes(minutes: Any) -> str:
|
||||
try:
|
||||
total = int(minutes or 0)
|
||||
except (TypeError, ValueError):
|
||||
return "0分钟"
|
||||
if total <= 0:
|
||||
return "0分钟"
|
||||
hours = total // 60
|
||||
mins = total % 60
|
||||
if hours:
|
||||
return f"{hours}小时{mins}分钟"
|
||||
return f"{mins}分钟"
|
||||
|
||||
|
||||
def _normalize_monitor_type_label(raw: Any) -> str:
|
||||
mt = str(raw or "").strip()
|
||||
if mt in ("trend_pullback", "trend"):
|
||||
return MONITOR_TYPE_TREND_PULLBACK
|
||||
if mt in ("roll",):
|
||||
return MONITOR_TYPE_ROLL
|
||||
return mt
|
||||
|
||||
|
||||
def effective_entry_type(d: dict) -> str:
|
||||
"""复盘开仓类型优先,与实例交易记录 effective_entry_reason 一致."""
|
||||
er = _effective_field(d, "reviewed_entry_reason", "entry_reason")
|
||||
if er is not None and str(er).strip():
|
||||
return str(er).strip()
|
||||
mt = _normalize_monitor_type_label(d.get("monitor_type"))
|
||||
er2 = entry_reason_for_monitor_type(mt)
|
||||
if er2:
|
||||
return er2
|
||||
kst = str(d.get("key_signal_type") or "").strip()
|
||||
if kst:
|
||||
return kst
|
||||
legacy = str(d.get("entry_type") or "").strip()
|
||||
if legacy and legacy not in ("trend_pullback", "roll", "trend"):
|
||||
return _normalize_monitor_type_label(legacy) or legacy
|
||||
return mt
|
||||
|
||||
|
||||
def display_entry_type_label(d: dict) -> str:
|
||||
"""档案/列表展示用开仓类型(不回落为「下单监控」若已有复盘或建档类型)."""
|
||||
label = effective_entry_type(d).strip()
|
||||
if not label:
|
||||
return "—"
|
||||
formatted = format_entry_type_display(
|
||||
label,
|
||||
entry_model=d.get("entry_model"),
|
||||
trade_style=d.get("trade_style"),
|
||||
)
|
||||
out = _normalize_monitor_type_label(formatted) or formatted
|
||||
return out or "—"
|
||||
|
||||
|
||||
def effective_hold_minutes(
|
||||
d: dict,
|
||||
*,
|
||||
opened_ms: int | None = None,
|
||||
closed_ms: int | None = None,
|
||||
) -> int:
|
||||
hm = _effective_field(d, "reviewed_hold_minutes", "hold_minutes")
|
||||
if hm is not None and str(hm).strip() != "":
|
||||
try:
|
||||
return max(0, int(hm))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
hs = _effective_field(d, "reviewed_hold_seconds", "hold_seconds")
|
||||
if hs is not None and str(hs).strip() != "":
|
||||
try:
|
||||
return max(0, int(int(hs) // 60))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
oms = opened_ms if opened_ms is not None else d.get("opened_at_ms")
|
||||
cms = closed_ms if closed_ms is not None else d.get("closed_at_ms")
|
||||
try:
|
||||
oms_i = int(oms) if oms not in (None, "") else None
|
||||
cms_i = int(cms) if cms not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
oms_i = cms_i = None
|
||||
if oms_i and cms_i and cms_i > oms_i:
|
||||
return max(0, int((cms_i - oms_i) // 60_000))
|
||||
return 0
|
||||
|
||||
|
||||
def _effective_pnl(d: dict) -> float:
|
||||
reviewed = d.get("reviewed_pnl_amount")
|
||||
if reviewed is not None and str(reviewed).strip() != "":
|
||||
try:
|
||||
return float(reviewed)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
ex = d.get("exchange_realized_pnl")
|
||||
if ex is not None and str(ex).strip() != "":
|
||||
try:
|
||||
return float(ex)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
return float(d.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _trade_close_dt(d: dict) -> datetime | None:
|
||||
raw = _effective_field(d, "reviewed_closed_at", "closed_at")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
raw = d.get("created_at") or d.get("opened_at")
|
||||
return parse_dt_for_trading_day(raw)
|
||||
|
||||
|
||||
def _normalize_trade_row(
|
||||
d: dict,
|
||||
*,
|
||||
trading_day: str,
|
||||
reset_hour: int,
|
||||
) -> dict[str, Any] | None:
|
||||
effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip()
|
||||
if effective_result not in TRADE_COMPLETED_RESULTS:
|
||||
return None
|
||||
close_dt = _trade_close_dt(d)
|
||||
if not close_dt:
|
||||
return None
|
||||
if trading_day_from_dt(close_dt, reset_hour) != trading_day:
|
||||
return None
|
||||
pnl = _effective_pnl(d)
|
||||
closed_at = _effective_field(d, "reviewed_closed_at", "closed_at")
|
||||
opened_at = _effective_field(d, "reviewed_opened_at", "opened_at")
|
||||
return {
|
||||
"symbol": d.get("symbol"),
|
||||
"direction": d.get("direction"),
|
||||
"result": effective_result,
|
||||
"pnl_amount": round(pnl, 4),
|
||||
"closed_at": closed_at,
|
||||
"opened_at": opened_at,
|
||||
"monitor_type": d.get("monitor_type"),
|
||||
"actual_rr": d.get("actual_rr"),
|
||||
"planned_rr": d.get("planned_rr"),
|
||||
"trade_style": d.get("trade_style"),
|
||||
"entry_reason": d.get("entry_reason"),
|
||||
"reviewed": bool(d.get("reviewed_at") or d.get("reviewed_result")),
|
||||
}
|
||||
|
||||
|
||||
def fetch_trades_for_trading_day(
|
||||
conn,
|
||||
trading_day: str,
|
||||
*,
|
||||
row_to_dict_fn: Optional[Callable] = None,
|
||||
reset_hour: int = 8,
|
||||
limit: int = 200,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""返回指定交易日的已平仓记录(与 /records 交易记录一致,复盘字段优先)."""
|
||||
day = (trading_day or "").strip()[:10]
|
||||
if not day:
|
||||
return []
|
||||
lim = max(1, min(int(limit or 200), 500))
|
||||
start_bj, end_bj = trading_day_window_bounds(day, reset_hour)
|
||||
ts_expr = "REPLACE(COALESCE(reviewed_closed_at, closed_at, created_at, opened_at), 'T', ' ')"
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT symbol, direction, result, reviewed_result, pnl_amount, reviewed_pnl_amount,
|
||||
exchange_realized_pnl, closed_at, reviewed_closed_at, opened_at, reviewed_opened_at,
|
||||
created_at, monitor_type, actual_rr, planned_rr, trade_style, entry_reason,
|
||||
reviewed_at
|
||||
FROM trade_records
|
||||
WHERE {ts_expr} >= ? AND {ts_expr} <= ?
|
||||
ORDER BY {ts_expr} ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(start_bj, end_bj, lim * 3),
|
||||
).fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
d = _row_dict(row, row_to_dict_fn)
|
||||
norm = _normalize_trade_row(d, trading_day=day, reset_hour=reset_hour)
|
||||
if norm:
|
||||
out.append(norm)
|
||||
if len(out) >= lim:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_archive_trade_row(
|
||||
d: dict,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any] | None:
|
||||
"""全历史档案用:已平仓记录(不按交易日截断)."""
|
||||
effective_result = str(_effective_field(d, "reviewed_result", "result") or "").strip()
|
||||
if effective_result not in TRADE_COMPLETED_RESULTS:
|
||||
return None
|
||||
close_dt = _trade_close_dt(d)
|
||||
if not close_dt:
|
||||
return None
|
||||
pnl = _effective_pnl(d)
|
||||
closed_at = _effective_field(d, "reviewed_closed_at", "closed_at")
|
||||
opened_at = _effective_field(d, "reviewed_opened_at", "opened_at")
|
||||
opened_ms = d.get("opened_at_ms")
|
||||
closed_ms = d.get("closed_at_ms")
|
||||
if opened_ms in (None, ""):
|
||||
odt = parse_dt_for_trading_day(opened_at)
|
||||
opened_ms = int(odt.timestamp() * 1000) if odt else None
|
||||
if closed_ms in (None, ""):
|
||||
cdt = close_dt
|
||||
closed_ms = int(cdt.timestamp() * 1000) if cdt else None
|
||||
try:
|
||||
trade_id = int(d.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
opened_ms_i = int(opened_ms) if opened_ms else None
|
||||
closed_ms_i = int(closed_ms) if closed_ms else None
|
||||
hold_m = effective_hold_minutes(d, opened_ms=opened_ms_i, closed_ms=closed_ms_i)
|
||||
entry_type = display_entry_type_label(d)
|
||||
reviewed = bool(
|
||||
d.get("reviewed_at")
|
||||
or d.get("reviewed_result")
|
||||
or d.get("reviewed_opened_at")
|
||||
or d.get("reviewed_closed_at")
|
||||
or d.get("reviewed_entry_reason")
|
||||
or d.get("reviewed_hold_minutes")
|
||||
)
|
||||
return {
|
||||
"id": trade_id,
|
||||
"exchange_key": (exchange_key or "").strip().lower(),
|
||||
"symbol": (d.get("symbol") or "").strip().upper(),
|
||||
"direction": d.get("direction"),
|
||||
"result": effective_result,
|
||||
"pnl_amount": round(pnl, 4),
|
||||
"closed_at": closed_at,
|
||||
"opened_at": opened_at,
|
||||
"opened_at_ms": opened_ms_i,
|
||||
"closed_at_ms": closed_ms_i,
|
||||
"monitor_type": _normalize_monitor_type_label(d.get("monitor_type")),
|
||||
"entry_type": entry_type,
|
||||
"entry_reason": entry_type,
|
||||
"hold_minutes": hold_m,
|
||||
"hold_minutes_text": format_hold_minutes(hold_m),
|
||||
"actual_rr": d.get("actual_rr"),
|
||||
"planned_rr": d.get("planned_rr"),
|
||||
"trade_style": d.get("trade_style"),
|
||||
"trigger_price": d.get("trigger_price"),
|
||||
"stop_loss": _effective_field(d, "reviewed_stop_loss", "stop_loss"),
|
||||
"take_profit": _effective_field(d, "reviewed_take_profit", "take_profit"),
|
||||
"reviewed": reviewed,
|
||||
"trading_day": trading_day_from_dt(close_dt, reset_hour),
|
||||
"exchange_turnover_usdt": d.get("exchange_turnover_usdt"),
|
||||
"exchange_commission_usdt": d.get("exchange_commission_usdt"),
|
||||
}
|
||||
|
||||
|
||||
_SNAPSHOT_STATUS_TO_RESULT = {
|
||||
"stopped_sl": "止损",
|
||||
"stopped_tp": "止盈",
|
||||
"stopped_manual": "手动平仓",
|
||||
"stopped_external": "外部平仓",
|
||||
}
|
||||
|
||||
|
||||
def _table_columns(conn, table: str) -> set[str]:
|
||||
try:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
except Exception:
|
||||
return set()
|
||||
out: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
out.add(str(r[1]))
|
||||
except (IndexError, TypeError):
|
||||
try:
|
||||
out.add(str(r["name"]))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _archive_ts_expr(cols: set[str]) -> str:
|
||||
parts = [c for c in ("reviewed_closed_at", "closed_at", "created_at", "opened_at") if c in cols]
|
||||
if not parts:
|
||||
return "''"
|
||||
return f"REPLACE(COALESCE({', '.join(parts)}), 'T', ' ')"
|
||||
|
||||
|
||||
def _archive_trade_select_sql(cols: set[str]) -> str:
|
||||
wanted = [
|
||||
"id",
|
||||
"symbol",
|
||||
"direction",
|
||||
"result",
|
||||
"reviewed_result",
|
||||
"pnl_amount",
|
||||
"reviewed_pnl_amount",
|
||||
"exchange_realized_pnl",
|
||||
"closed_at",
|
||||
"reviewed_closed_at",
|
||||
"opened_at",
|
||||
"reviewed_opened_at",
|
||||
"opened_at_ms",
|
||||
"closed_at_ms",
|
||||
"created_at",
|
||||
"monitor_type",
|
||||
"key_signal_type",
|
||||
"actual_rr",
|
||||
"planned_rr",
|
||||
"trade_style",
|
||||
"entry_reason",
|
||||
"reviewed_entry_reason",
|
||||
"hold_minutes",
|
||||
"reviewed_hold_minutes",
|
||||
"hold_seconds",
|
||||
"reviewed_hold_seconds",
|
||||
"trigger_price",
|
||||
"stop_loss",
|
||||
"take_profit",
|
||||
"reviewed_stop_loss",
|
||||
"reviewed_take_profit",
|
||||
"reviewed_at",
|
||||
"trend_plan_id",
|
||||
"exchange_turnover_usdt",
|
||||
"exchange_commission_usdt",
|
||||
]
|
||||
select_cols = [c for c in wanted if c in cols]
|
||||
if "id" not in select_cols:
|
||||
select_cols = ["id"] + select_cols
|
||||
return ", ".join(select_cols)
|
||||
|
||||
|
||||
def _existing_trend_plan_ids(conn) -> set[int]:
|
||||
cols = _table_columns(conn, "trade_records")
|
||||
if "trend_plan_id" not in cols:
|
||||
return set()
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trend_plan_id FROM trade_records WHERE trend_plan_id IS NOT NULL"
|
||||
).fetchall()
|
||||
out: set[int] = set()
|
||||
for row in rows:
|
||||
d = _row_dict(row)
|
||||
try:
|
||||
out.add(int(d.get("trend_plan_id")))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_snapshot_archive_row(
|
||||
snap: dict,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
reset_hour: int = 8,
|
||||
) -> dict[str, Any] | None:
|
||||
result = str(snap.get("result_label") or "").strip()
|
||||
if not result:
|
||||
result = _SNAPSHOT_STATUS_TO_RESULT.get(
|
||||
str(snap.get("status_at_close") or "").strip(), ""
|
||||
)
|
||||
if result not in TRADE_COMPLETED_RESULTS:
|
||||
return None
|
||||
closed_at = snap.get("closed_at")
|
||||
close_dt = parse_dt_for_trading_day(closed_at)
|
||||
if not close_dt:
|
||||
return None
|
||||
opened_at = snap.get("opened_at")
|
||||
opened_ms = _parse_ms_from_row(snap.get("opened_at"))
|
||||
closed_ms = _parse_ms_from_row(closed_at)
|
||||
try:
|
||||
snap_id = int(snap.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
try:
|
||||
pnl = float(snap.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pnl = 0.0
|
||||
st = str(snap.get("strategy_type") or "").strip()
|
||||
monitor_type = _normalize_monitor_type_label(
|
||||
"trend_pullback" if st == "trend_pullback" else ("roll" if st == "roll" else st)
|
||||
)
|
||||
hold_m = effective_hold_minutes(
|
||||
{},
|
||||
opened_ms=opened_ms,
|
||||
closed_ms=closed_ms,
|
||||
)
|
||||
entry_type = entry_reason_for_monitor_type(monitor_type) or monitor_type
|
||||
return {
|
||||
"id": -snap_id,
|
||||
"exchange_key": (exchange_key or "").strip().lower(),
|
||||
"symbol": (snap.get("symbol") or "").strip().upper(),
|
||||
"direction": snap.get("direction"),
|
||||
"result": result,
|
||||
"pnl_amount": round(pnl, 4),
|
||||
"closed_at": closed_at,
|
||||
"opened_at": opened_at,
|
||||
"opened_at_ms": opened_ms,
|
||||
"closed_at_ms": closed_ms,
|
||||
"monitor_type": monitor_type,
|
||||
"entry_type": entry_type,
|
||||
"entry_reason": entry_type,
|
||||
"hold_minutes": hold_m,
|
||||
"hold_minutes_text": format_hold_minutes(hold_m),
|
||||
"from_snapshot": True,
|
||||
"snapshot_id": snap_id,
|
||||
"trend_plan_id": snap.get("source_id"),
|
||||
"reviewed": False,
|
||||
"trading_day": trading_day_from_dt(close_dt, reset_hour),
|
||||
}
|
||||
|
||||
|
||||
def _parse_ms_from_row(raw: Any) -> int | None:
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
if isinstance(raw, (int, float)):
|
||||
v = int(raw)
|
||||
return v if v > 1_000_000_000_000 else v * 1000
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
dt = parse_dt_for_trading_day(raw)
|
||||
return int(dt.timestamp() * 1000) if dt else None
|
||||
|
||||
|
||||
def _fetch_strategy_snapshots_for_archive(
|
||||
conn,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
days: int = 365,
|
||||
reset_hour: int = 8,
|
||||
limit: int = 2000,
|
||||
skip_plan_ids: set[int] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
cols = _table_columns(conn, "strategy_trade_snapshots")
|
||||
if not cols:
|
||||
return []
|
||||
lim = max(1, min(int(limit or 2000), 5000))
|
||||
day_span = max(1, min(int(days or 365), 3650))
|
||||
cutoff = datetime.now() - timedelta(days=day_span)
|
||||
cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
||||
ts_expr = "REPLACE(COALESCE(closed_at, opened_at, created_at), 'T', ' ')"
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT * FROM strategy_trade_snapshots
|
||||
WHERE {ts_expr} >= ?
|
||||
ORDER BY {ts_expr} DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(cutoff_s, lim * 2),
|
||||
).fetchall()
|
||||
skip = skip_plan_ids or set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
d = _row_dict(row)
|
||||
try:
|
||||
source_id = int(d.get("source_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
source_id = 0
|
||||
if source_id > 0 and source_id in skip:
|
||||
continue
|
||||
norm = _normalize_snapshot_archive_row(
|
||||
d, exchange_key=exchange_key, reset_hour=reset_hour
|
||||
)
|
||||
if norm:
|
||||
out.append(norm)
|
||||
if len(out) >= lim:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def fetch_trades_for_archive(
|
||||
conn,
|
||||
*,
|
||||
exchange_key: str = "",
|
||||
days: int = 365,
|
||||
row_to_dict_fn: Optional[Callable] = None,
|
||||
reset_hour: int = 8,
|
||||
limit: int = 2000,
|
||||
include_strategy_snapshots: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""返回近 N 天已平仓记录(trade_records + 未落库的 strategy 快照)."""
|
||||
lim = max(1, min(int(limit or 2000), 5000))
|
||||
day_span = max(1, min(int(days or 365), 3650))
|
||||
cutoff = datetime.now() - timedelta(days=day_span)
|
||||
cutoff_s = cutoff.strftime("%Y-%m-%d %H:%M:%S")
|
||||
cols = _table_columns(conn, "trade_records")
|
||||
if not cols:
|
||||
records: list[dict[str, Any]] = []
|
||||
else:
|
||||
ts_expr = _archive_ts_expr(cols)
|
||||
sql = f"""
|
||||
SELECT {_archive_trade_select_sql(cols)}
|
||||
FROM trade_records
|
||||
WHERE {ts_expr} >= ?
|
||||
ORDER BY {ts_expr} DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = conn.execute(sql, (cutoff_s, lim * 2)).fetchall()
|
||||
records = []
|
||||
for row in rows:
|
||||
d = _row_dict(row, row_to_dict_fn)
|
||||
norm = _normalize_archive_trade_row(
|
||||
d, exchange_key=exchange_key, reset_hour=reset_hour
|
||||
)
|
||||
if norm:
|
||||
records.append(norm)
|
||||
if len(records) >= lim:
|
||||
break
|
||||
|
||||
if not include_strategy_snapshots:
|
||||
return records
|
||||
|
||||
skip_ids = _existing_trend_plan_ids(conn)
|
||||
for rec in records:
|
||||
try:
|
||||
pid = int(rec.get("trend_plan_id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pid = 0
|
||||
if pid > 0:
|
||||
skip_ids.add(pid)
|
||||
|
||||
snaps = _fetch_strategy_snapshots_for_archive(
|
||||
conn,
|
||||
days=days,
|
||||
exchange_key=exchange_key,
|
||||
reset_hour=reset_hour,
|
||||
limit=max(0, lim - len(records)),
|
||||
skip_plan_ids=skip_ids,
|
||||
)
|
||||
merged = records + snaps
|
||||
merged.sort(
|
||||
key=lambda x: int(x.get("closed_at_ms") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
merged = merged[:lim]
|
||||
attach_journal_mood_tags(conn, merged, cutoff_s=cutoff_s)
|
||||
return merged
|
||||
|
||||
|
||||
def _symbol_coin_base(symbol: str) -> str:
|
||||
s = (symbol or "").strip().upper()
|
||||
if "/" in s:
|
||||
return s.split("/")[0]
|
||||
return s
|
||||
|
||||
|
||||
def _datetime_minute_key(raw: Any) -> str:
|
||||
if raw is None:
|
||||
return ""
|
||||
s = str(raw).strip().replace("T", " ").replace("Z", "")
|
||||
if len(s) >= 16:
|
||||
return s[:16]
|
||||
return s[:10] if len(s) >= 10 else s
|
||||
|
||||
|
||||
def journal_trade_match_key(symbol: str, opened_at: Any, closed_at: Any) -> tuple[str, str, str]:
|
||||
return (
|
||||
_symbol_coin_base(symbol),
|
||||
_datetime_minute_key(opened_at),
|
||||
_datetime_minute_key(closed_at),
|
||||
)
|
||||
|
||||
|
||||
def load_journal_mood_match_index(
|
||||
conn,
|
||||
*,
|
||||
cutoff_s: str,
|
||||
) -> dict[tuple[str, str, str], list[str]]:
|
||||
"""复盘 mood_issues → 交易匹配键(币种 + 开/平仓分钟)."""
|
||||
from lib.trade.account_risk_lib import parse_mood_issues
|
||||
|
||||
cols = _table_columns(conn, "journal_entries")
|
||||
if not cols or "mood_issues" not in cols:
|
||||
return {}
|
||||
close_expr = "REPLACE(COALESCE(close_datetime, open_datetime, created_at), 'T', ' ')"
|
||||
open_expr = "REPLACE(COALESCE(open_datetime, created_at), 'T', ' ')"
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT coin, open_datetime, close_datetime, mood_issues
|
||||
FROM journal_entries
|
||||
WHERE {close_expr} >= ? OR {open_expr} >= ?
|
||||
""",
|
||||
(cutoff_s, cutoff_s),
|
||||
).fetchall()
|
||||
out: dict[tuple[str, str, str], list[str]] = {}
|
||||
for row in rows:
|
||||
d = _row_dict(row)
|
||||
issues = parse_mood_issues(d.get("mood_issues"))
|
||||
if not issues:
|
||||
continue
|
||||
coin = str(d.get("coin") or "").strip().upper()
|
||||
sym = coin if "/" in coin else (f"{coin}/USDT" if coin else "")
|
||||
key = journal_trade_match_key(sym, d.get("open_datetime"), d.get("close_datetime"))
|
||||
if not key[0]:
|
||||
continue
|
||||
out[key] = issues
|
||||
return out
|
||||
|
||||
|
||||
def attach_journal_mood_tags(
|
||||
conn,
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
cutoff_s: str,
|
||||
) -> None:
|
||||
"""实例复盘勾选情绪标签 → 档案交易自动标犯病(hub 同步用)."""
|
||||
if not trades:
|
||||
return
|
||||
mood_index = load_journal_mood_match_index(conn, cutoff_s=cutoff_s)
|
||||
if not mood_index:
|
||||
return
|
||||
for t in trades:
|
||||
if not isinstance(t, dict):
|
||||
continue
|
||||
key = journal_trade_match_key(
|
||||
str(t.get("symbol") or ""),
|
||||
t.get("opened_at"),
|
||||
t.get("closed_at"),
|
||||
)
|
||||
issues = mood_index.get(key)
|
||||
if not issues:
|
||||
continue
|
||||
t["journal_mood_issues"] = issues
|
||||
t["journal_mood_sick"] = True
|
||||
t["behavior_tag_from_journal"] = True
|
||||
t["behavior_tag"] = "sick"
|
||||
|
||||
|
||||
def summarize_trades(trades: list[dict]) -> dict[str, Any]:
|
||||
"""单笔列表 → 笔数 / 盈亏 / 胜败统计."""
|
||||
total_pnl = 0.0
|
||||
win_pnl = 0.0
|
||||
loss_pnl = 0.0
|
||||
win = loss = flat = 0
|
||||
for t in trades or []:
|
||||
try:
|
||||
pnl = float(t.get("pnl_amount") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pnl = 0.0
|
||||
total_pnl += pnl
|
||||
if pnl > 1e-9:
|
||||
win += 1
|
||||
win_pnl += pnl
|
||||
elif pnl < -1e-9:
|
||||
loss += 1
|
||||
loss_pnl += pnl
|
||||
else:
|
||||
flat += 1
|
||||
return {
|
||||
"closed_count": len(trades or []),
|
||||
"win_count": win,
|
||||
"loss_count": loss,
|
||||
"flat_count": flat,
|
||||
"total_pnl_u": round(total_pnl, 4),
|
||||
"win_pnl_u": round(win_pnl, 4),
|
||||
"loss_pnl_u": round(loss_pnl, 4),
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
"""行情区:各交易所 USDT 永续昨日成交额 Top N(每日 8:00 快照)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.hub.hub_trades_lib import trading_day_from_dt
|
||||
|
||||
TOP_N_DEFAULT = 20
|
||||
CACHE_VERSION = 3
|
||||
LIQUIDITY_RANK_CACHE_VERSION = 1
|
||||
|
||||
|
||||
def volume_rank_reset_hour() -> int:
|
||||
try:
|
||||
return max(0, min(23, int(os.getenv("HUB_VOLUME_RANK_RESET_HOUR", "8"))))
|
||||
except ValueError:
|
||||
return 8
|
||||
|
||||
|
||||
def volume_rank_timezone() -> ZoneInfo:
|
||||
name = (os.getenv("HUB_VOLUME_RANK_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def rank_date_label(*, now: datetime | None = None, reset_hour: int | None = None) -> str:
|
||||
"""8 点更新后展示的「昨日」交易日(与 TRADING_DAY_RESET_HOUR 口径一致)."""
|
||||
rh = volume_rank_reset_hour() if reset_hour is None else reset_hour
|
||||
tz = volume_rank_timezone()
|
||||
dt = now.astimezone(tz) if now else datetime.now(tz)
|
||||
cur_td = trading_day_from_dt(dt.replace(tzinfo=None), rh)
|
||||
cur = datetime.strptime(cur_td, "%Y-%m-%d").date()
|
||||
return (cur - timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
def seconds_until_next_reset(
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
reset_hour: int | None = None,
|
||||
) -> float:
|
||||
rh = volume_rank_reset_hour() if reset_hour is None else reset_hour
|
||||
tz = volume_rank_timezone()
|
||||
dt = now.astimezone(tz) if now else datetime.now(tz)
|
||||
nxt = dt.replace(hour=rh, minute=0, second=0, microsecond=0)
|
||||
if dt >= nxt:
|
||||
nxt += timedelta(days=1)
|
||||
return max(1.0, (nxt - dt).total_seconds())
|
||||
|
||||
|
||||
def default_cache_path() -> Path:
|
||||
raw = (os.getenv("HUB_VOLUME_RANK_CACHE_PATH") or "").strip()
|
||||
if raw:
|
||||
return Path(raw)
|
||||
from lib.paths import hub_data_dir
|
||||
|
||||
return hub_data_dir() / "hub_volume_rank.json"
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> float | None:
|
||||
try:
|
||||
n = float(v)
|
||||
return n if n == n else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ticker_base(sym_text: str) -> str:
|
||||
s = str(sym_text or "").upper().strip()
|
||||
if ":" in s:
|
||||
s = s.split(":", 1)[0]
|
||||
if "/" in s:
|
||||
return s.split("/", 1)[0].strip()
|
||||
if "-" in s:
|
||||
return s.split("-", 1)[0].strip()
|
||||
if s.endswith("USDT"):
|
||||
return s[:-4].strip()
|
||||
return s
|
||||
|
||||
|
||||
def _hub_symbol_from_base(base: str, quote: str = "USDT") -> str:
|
||||
b = str(base or "").strip().upper()
|
||||
q = str(quote or "USDT").strip().upper()
|
||||
return f"{b}/{q}" if b else ""
|
||||
|
||||
|
||||
def _hub_symbol_from_market(market: dict | None, fallback_symbol: str) -> str:
|
||||
if market:
|
||||
base = str(market.get("base") or "").strip().upper()
|
||||
quote = str(market.get("quote") or "USDT").strip().upper()
|
||||
if base:
|
||||
return f"{base}/{quote}"
|
||||
fb = str(fallback_symbol or "").upper().strip()
|
||||
if ":" in fb:
|
||||
fb = fb.split(":", 1)[0]
|
||||
if "/" in fb:
|
||||
return fb
|
||||
base = _ticker_base(fb)
|
||||
return f"{base}/USDT" if base else fb
|
||||
|
||||
|
||||
def _okx_turnover_usdt(row: dict | None) -> float | None:
|
||||
"""OKX SWAP:成交额(USDT) ≈ volCcy24h(基础币) × last."""
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
base_vol = _safe_float(row.get("volCcy24h"))
|
||||
if base_vol is None or base_vol <= 0:
|
||||
return None
|
||||
last = _safe_float(row.get("last") or row.get("lastPx"))
|
||||
if last is None or last <= 0:
|
||||
return None
|
||||
return float(base_vol * last)
|
||||
|
||||
|
||||
def _quote_volume_from_ticker(
|
||||
ticker: dict | None,
|
||||
market: dict | None,
|
||||
*,
|
||||
exchange_id: str = "",
|
||||
) -> float | None:
|
||||
ex_id = str(exchange_id or "").lower()
|
||||
t = ticker or {}
|
||||
info = t.get("info") if isinstance(t.get("info"), dict) else {}
|
||||
|
||||
if ex_id == "okx":
|
||||
row = dict(info)
|
||||
if row.get("last") is None:
|
||||
row["last"] = t.get("last")
|
||||
qv = _okx_turnover_usdt(row)
|
||||
if qv is not None and qv > 0:
|
||||
return qv
|
||||
|
||||
qv = _safe_float(t.get("quoteVolume"))
|
||||
if qv is not None and qv > 0:
|
||||
return qv
|
||||
|
||||
if ex_id in ("gateio", "gate"):
|
||||
for key in (
|
||||
"volume_24h_quote",
|
||||
"volume_24h_settle",
|
||||
"quote_volume",
|
||||
"vol_24h",
|
||||
"turnover",
|
||||
):
|
||||
qv = _safe_float(info.get(key))
|
||||
if qv is not None and qv > 0:
|
||||
return qv
|
||||
|
||||
for key in ("quoteVolume", "volCcy24h", "vol24h", "turnover24h", "amount24", "turnover"):
|
||||
qv = _safe_float(info.get(key))
|
||||
if qv is not None and qv > 0:
|
||||
if key == "volCcy24h" and ex_id == "okx":
|
||||
last = _safe_float(info.get("last") or info.get("lastPx") or t.get("last"))
|
||||
if last:
|
||||
return qv * last
|
||||
return qv
|
||||
|
||||
bv = _safe_float(t.get("baseVolume"))
|
||||
lp = _safe_float(t.get("last")) or _safe_float(t.get("close"))
|
||||
if bv is not None and lp is not None and bv > 0 and lp > 0:
|
||||
return bv * lp
|
||||
|
||||
if info:
|
||||
bv = _safe_float(info.get("volCcy24h") or info.get("vol24h") or info.get("volume"))
|
||||
lp = _safe_float(info.get("last") or info.get("lastPx") or info.get("markPrice"))
|
||||
if bv is not None and lp is not None and bv > 0 and lp > 0:
|
||||
return bv * lp
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _is_usdt_linear_swap(market: dict | None, symbol: str) -> bool:
|
||||
if not market:
|
||||
su = str(symbol or "").upper()
|
||||
return "USDT" in su and (":USDT" in su or "/USDT" in su or su.endswith("USDT"))
|
||||
if not market.get("swap") and market.get("type") not in ("swap", "future"):
|
||||
return False
|
||||
if str(market.get("quote") or "").upper() != "USDT":
|
||||
return False
|
||||
if market.get("linear") is False:
|
||||
return False
|
||||
if market.get("active") is False:
|
||||
return False
|
||||
settle = str(market.get("settle") or "").upper()
|
||||
if settle and settle != "USDT":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _lookup_ticker(tickers: dict, sym: str, market: dict | None) -> dict | None:
|
||||
if not tickers:
|
||||
return None
|
||||
t = tickers.get(sym)
|
||||
if t:
|
||||
return t
|
||||
if not market:
|
||||
return None
|
||||
base = market.get("base")
|
||||
quote = market.get("quote") or "USDT"
|
||||
settle = market.get("settle") or quote
|
||||
candidates = [
|
||||
sym,
|
||||
f"{base}/{quote}:{settle}",
|
||||
f"{base}/{quote}",
|
||||
f"{base}{quote}",
|
||||
market.get("id"),
|
||||
]
|
||||
for key in candidates:
|
||||
if not key:
|
||||
continue
|
||||
t = tickers.get(key)
|
||||
if t:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def _merge_scores(scored: dict[str, tuple[str, float]]) -> list[tuple[str, str, float]]:
|
||||
rows = [(sym, base, vol) for base, (sym, vol) in scored.items() if sym and base and vol > 0]
|
||||
rows.sort(key=lambda x: x[2], reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _scores_from_okx(exchange) -> list[tuple[str, str, float]]:
|
||||
by_base: dict[str, tuple[str, float]] = {}
|
||||
if hasattr(exchange, "publicGetMarketTickers"):
|
||||
try:
|
||||
resp = exchange.publicGetMarketTickers({"instType": "SWAP"})
|
||||
for row in (resp or {}).get("data") or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
inst = str(row.get("instId") or "").upper()
|
||||
parts = inst.split("-")
|
||||
if len(parts) < 3 or parts[-1] != "SWAP" or parts[1] != "USDT":
|
||||
continue
|
||||
base = parts[0].strip()
|
||||
if not base:
|
||||
continue
|
||||
qv = _okx_turnover_usdt(row)
|
||||
if qv is None or qv <= 0:
|
||||
continue
|
||||
sym = _hub_symbol_from_base(base)
|
||||
prev = by_base.get(base)
|
||||
if prev is None or qv > prev[1]:
|
||||
by_base[base] = (sym, float(qv))
|
||||
if by_base:
|
||||
return _merge_scores(by_base)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tickers = exchange.fetch_tickers(params={"instType": "SWAP"})
|
||||
except Exception:
|
||||
tickers = exchange.fetch_tickers()
|
||||
return _scores_from_markets(exchange, tickers or {}, "okx")
|
||||
|
||||
|
||||
def _scores_from_binance(exchange) -> list[tuple[str, str, float]]:
|
||||
by_base: dict[str, tuple[str, float]] = {}
|
||||
if hasattr(exchange, "fapiPublicGetTicker24hr"):
|
||||
try:
|
||||
rows = exchange.fapiPublicGetTicker24hr()
|
||||
if isinstance(rows, list):
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
raw = str(row.get("symbol") or "").upper()
|
||||
if not raw.endswith("USDT"):
|
||||
continue
|
||||
base = raw[:-4]
|
||||
if not base:
|
||||
continue
|
||||
qv = _safe_float(row.get("quoteVolume"))
|
||||
if qv is None or qv <= 0:
|
||||
bv = _safe_float(row.get("volume"))
|
||||
lp = _safe_float(row.get("lastPrice") or row.get("weightedAvgPrice"))
|
||||
if bv and lp:
|
||||
qv = bv * lp
|
||||
if qv is None or qv <= 0:
|
||||
continue
|
||||
sym = _hub_symbol_from_base(base)
|
||||
prev = by_base.get(base)
|
||||
if prev is None or qv > prev[1]:
|
||||
by_base[base] = (sym, float(qv))
|
||||
if by_base:
|
||||
return _merge_scores(by_base)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _scores_from_gate(exchange) -> list[tuple[str, str, float]]:
|
||||
by_base: dict[str, tuple[str, float]] = {}
|
||||
for method_name in ("publicFuturesGetSettleTickers", "publicFuturesGetUsdtTickers"):
|
||||
fn = getattr(exchange, method_name, None)
|
||||
if not callable(fn):
|
||||
continue
|
||||
try:
|
||||
rows = fn({"settle": "usdt"})
|
||||
if isinstance(rows, list):
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
contract = str(row.get("contract") or row.get("name") or "").upper()
|
||||
if not contract:
|
||||
continue
|
||||
base = contract.replace("_USDT", "").replace("USDT", "").strip("_")
|
||||
if not base:
|
||||
continue
|
||||
qv = _safe_float(row.get("volume_24h_quote") or row.get("volume_24h_settle"))
|
||||
if qv is None or qv <= 0:
|
||||
bv = _safe_float(row.get("volume_24h_base"))
|
||||
lp = _safe_float(row.get("last") or row.get("mark_price"))
|
||||
if bv and lp:
|
||||
qv = bv * lp
|
||||
if qv is None or qv <= 0:
|
||||
continue
|
||||
sym = _hub_symbol_from_base(base)
|
||||
prev = by_base.get(base)
|
||||
if prev is None or qv > prev[1]:
|
||||
by_base[base] = (sym, float(qv))
|
||||
if by_base:
|
||||
return _merge_scores(by_base)
|
||||
except Exception:
|
||||
continue
|
||||
return []
|
||||
|
||||
|
||||
def _scores_from_markets(
|
||||
exchange,
|
||||
tickers: dict,
|
||||
exchange_id: str,
|
||||
) -> list[tuple[str, str, float]]:
|
||||
by_base: dict[str, tuple[str, float]] = {}
|
||||
markets = getattr(exchange, "markets", None) or {}
|
||||
for sym, mk in markets.items():
|
||||
try:
|
||||
if not _is_usdt_linear_swap(mk, sym):
|
||||
continue
|
||||
ticker = _lookup_ticker(tickers, sym, mk)
|
||||
qv = _quote_volume_from_ticker(ticker, mk, exchange_id=exchange_id)
|
||||
if qv is None or qv <= 0:
|
||||
continue
|
||||
hub_sym = _hub_symbol_from_market(mk, sym)
|
||||
base = _ticker_base(hub_sym)
|
||||
if not base:
|
||||
continue
|
||||
prev = by_base.get(base)
|
||||
if prev is None or qv > prev[1]:
|
||||
by_base[base] = (hub_sym, float(qv))
|
||||
except Exception:
|
||||
continue
|
||||
return _merge_scores(by_base)
|
||||
|
||||
|
||||
def _collect_scores(exchange, exchange_id: str) -> list[tuple[str, str, float]]:
|
||||
ex_id = str(exchange_id or "").lower()
|
||||
if ex_id == "okx":
|
||||
return _scores_from_okx(exchange)
|
||||
if ex_id == "binance":
|
||||
return _scores_from_binance(exchange)
|
||||
if ex_id in ("gateio", "gate"):
|
||||
return _scores_from_gate(exchange)
|
||||
tickers = exchange.fetch_tickers()
|
||||
return _scores_from_markets(exchange, tickers or {}, ex_id)
|
||||
|
||||
|
||||
def _uses_lightweight_volume_scores(exchange_id: str) -> bool:
|
||||
ex_id = str(exchange_id or "").lower()
|
||||
return ex_id in ("okx", "binance", "gateio", "gate")
|
||||
|
||||
|
||||
def build_usdt_swap_volume_ranks(
|
||||
exchange,
|
||||
ensure_markets_loaded: Callable[[], None],
|
||||
*,
|
||||
exchange_id: str | None = None,
|
||||
) -> tuple[dict[str, int], int]:
|
||||
"""
|
||||
全市场 USDT 永续 24h 成交额排名(base -> rank).
|
||||
优先各所轻量 ticker API,避免 fetch_tickers() 拉全市场(Gate/Binance 内存优化).
|
||||
"""
|
||||
ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower()
|
||||
if not _uses_lightweight_volume_scores(ex_id):
|
||||
ensure_markets_loaded()
|
||||
scored = _collect_scores(exchange, ex_id)
|
||||
ranks: dict[str, int] = {}
|
||||
for idx, (_sym, base, _qv) in enumerate(scored, 1):
|
||||
if base and base not in ranks:
|
||||
ranks[base] = idx
|
||||
return ranks, len(scored)
|
||||
|
||||
|
||||
def resolve_daily_volume_rank(
|
||||
target_base: str,
|
||||
cache: dict[str, Any],
|
||||
*,
|
||||
now_ts: float,
|
||||
ttl_sec: float,
|
||||
exchange,
|
||||
ensure_markets_loaded: Callable[[], None],
|
||||
exchange_id: str | None = None,
|
||||
cache_version: int = LIQUIDITY_RANK_CACHE_VERSION,
|
||||
) -> tuple[int | None, int]:
|
||||
"""关键位门控:按 base 查 24h 成交额全市场排名;cache 带 TTL."""
|
||||
cached_ok = (
|
||||
cache.get("version") == cache_version
|
||||
and cache.get("updated_at")
|
||||
and now_ts - float(cache["updated_at"]) < ttl_sec
|
||||
)
|
||||
if not cached_ok:
|
||||
try:
|
||||
ranks, total = build_usdt_swap_volume_ranks(
|
||||
exchange,
|
||||
ensure_markets_loaded,
|
||||
exchange_id=exchange_id,
|
||||
)
|
||||
if total > 0 and ranks:
|
||||
cache["ranks"] = ranks
|
||||
cache["total"] = total
|
||||
cache["version"] = cache_version
|
||||
cache["updated_at"] = now_ts
|
||||
except Exception:
|
||||
pass
|
||||
ranks = cache.get("ranks") or {}
|
||||
total = int(cache.get("total") or 0)
|
||||
base = str(target_base or "").strip().upper()
|
||||
return ranks.get(base), total
|
||||
|
||||
|
||||
def fetch_usdt_swap_volume_rank(
|
||||
exchange,
|
||||
ensure_markets_loaded: Callable[[], None],
|
||||
*,
|
||||
top_n: int = TOP_N_DEFAULT,
|
||||
rank_date: str | None = None,
|
||||
exchange_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""从 ccxt 拉全市场 USDT 永续 ticker,按 24h 成交额(USDT) 取 Top N."""
|
||||
top_n = max(1, min(int(top_n or TOP_N_DEFAULT), 100))
|
||||
ensure_markets_loaded()
|
||||
ex_id = str(exchange_id or getattr(exchange, "id", "") or "").lower()
|
||||
|
||||
try:
|
||||
scored = _collect_scores(exchange, ex_id)
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
items = []
|
||||
for idx, (hub_sym, base, qv) in enumerate(scored[:top_n], 1):
|
||||
items.append(
|
||||
{
|
||||
"rank": idx,
|
||||
"symbol": hub_sym,
|
||||
"base": base,
|
||||
"volume_quote": round(qv, 4),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"rank_date": rank_date or rank_date_label(),
|
||||
"items": items,
|
||||
"total_symbols": len(scored),
|
||||
"exchange_id": ex_id,
|
||||
"fetched_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
def format_volume_quote(value: float | None) -> str:
|
||||
n = _safe_float(value)
|
||||
if n is None or n <= 0:
|
||||
return "—"
|
||||
if n >= 1e9:
|
||||
return f"{n / 1e9:.2f}B"
|
||||
if n >= 1e6:
|
||||
return f"{n / 1e6:.2f}M"
|
||||
if n >= 1e3:
|
||||
return f"{n / 1e3:.2f}K"
|
||||
return f"{n:.0f}"
|
||||
|
||||
|
||||
def load_volume_rank_cache(path: Path | None = None) -> dict[str, Any]:
|
||||
p = path or default_cache_path()
|
||||
if not p.is_file():
|
||||
return {"version": CACHE_VERSION, "exchanges": {}}
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return {"version": CACHE_VERSION, "exchanges": {}}
|
||||
if int(data.get("version") or 0) < CACHE_VERSION:
|
||||
return {"version": CACHE_VERSION, "exchanges": {}}
|
||||
data.setdefault("version", CACHE_VERSION)
|
||||
data.setdefault("exchanges", {})
|
||||
return data
|
||||
except Exception:
|
||||
return {"version": CACHE_VERSION, "exchanges": {}}
|
||||
|
||||
|
||||
def save_volume_rank_cache(data: dict[str, Any], path: Path | None = None) -> None:
|
||||
p = path or default_cache_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = dict(data)
|
||||
payload["version"] = CACHE_VERSION
|
||||
payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds")
|
||||
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def merge_exchange_rank(
|
||||
cache: dict[str, Any],
|
||||
exchange_key: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
ex_k = str(exchange_key or "").strip().lower()
|
||||
if not ex_k or not payload.get("ok"):
|
||||
return cache
|
||||
exchanges = dict(cache.get("exchanges") or {})
|
||||
exchanges[ex_k] = {
|
||||
"rank_date": payload.get("rank_date"),
|
||||
"items": payload.get("items") or [],
|
||||
"total_symbols": int(payload.get("total_symbols") or 0),
|
||||
"fetched_at": payload.get("fetched_at"),
|
||||
"error": None,
|
||||
}
|
||||
out = dict(cache)
|
||||
out["exchanges"] = exchanges
|
||||
out["rank_date"] = payload.get("rank_date") or cache.get("rank_date")
|
||||
return out
|
||||
|
||||
|
||||
def _exchange_rank_row_stale(row: dict[str, Any] | None) -> bool:
|
||||
if not row:
|
||||
return True
|
||||
items = row.get("items") or []
|
||||
if len(items) < TOP_N_DEFAULT:
|
||||
return True
|
||||
total = int(row.get("total_symbols") or 0)
|
||||
if total > 0 and total < TOP_N_DEFAULT:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cache_needs_refresh(
|
||||
cache: dict[str, Any],
|
||||
*,
|
||||
expected_rank_date: str | None = None,
|
||||
required_keys: list[str] | None = None,
|
||||
) -> bool:
|
||||
expected = expected_rank_date or rank_date_label()
|
||||
if int(cache.get("version") or 0) < CACHE_VERSION:
|
||||
return True
|
||||
exchanges = cache.get("exchanges") or {}
|
||||
if not exchanges:
|
||||
return True
|
||||
if str(cache.get("rank_date") or "") != expected:
|
||||
return True
|
||||
keys = required_keys or list(exchanges.keys())
|
||||
if not keys:
|
||||
return True
|
||||
for key in keys:
|
||||
ex_k = str(key or "").strip().lower()
|
||||
if not ex_k:
|
||||
continue
|
||||
if _exchange_rank_row_stale(exchanges.get(ex_k)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_cached_rank(
|
||||
cache: dict[str, Any],
|
||||
exchange_key: str,
|
||||
*,
|
||||
top_n: int = TOP_N_DEFAULT,
|
||||
) -> dict[str, Any]:
|
||||
ex_k = str(exchange_key or "").strip().lower()
|
||||
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
||||
items = list(ex_data.get("items") or [])[: max(1, int(top_n))]
|
||||
stale = _exchange_rank_row_stale(ex_data)
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange_key": ex_k,
|
||||
"rank_date": ex_data.get("rank_date") or cache.get("rank_date"),
|
||||
"updated_at": cache.get("updated_at"),
|
||||
"items": items,
|
||||
"item_count": len(items),
|
||||
"expected_count": int(top_n),
|
||||
"total_symbols": int(ex_data.get("total_symbols") or 0),
|
||||
"stale": stale,
|
||||
"error": ex_data.get("error"),
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Mapping, Optional, Sequence
|
||||
|
||||
from lib.hub.hub_position_metrics import parse_position_mark_price
|
||||
|
||||
|
||||
def resolve_order_snapshot_price(
|
||||
symbol: str,
|
||||
prices: Mapping[str, float],
|
||||
*,
|
||||
position_row: Optional[dict[str, Any]] = None,
|
||||
order_leverage=None,
|
||||
parse_position_metrics_fn: Callable[..., dict[str, Any] | None] | None = None,
|
||||
get_mark_price_fn: Callable[[str], float | None] | None = None,
|
||||
fallback_entry: float | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
解析下单监控轮询用的现价/标记价,优先级:
|
||||
1. 已批量拉取的 ticker last
|
||||
2. get_symbol_mark_price(含 mark)
|
||||
3. 交易所持仓 mark(parse_ccxt_position_metrics / parse_position_mark_price)
|
||||
4. 计划成交价 trigger_price
|
||||
"""
|
||||
sym = (symbol or "").strip()
|
||||
if not sym:
|
||||
return None
|
||||
|
||||
cached = prices.get(sym)
|
||||
if cached is not None:
|
||||
try:
|
||||
v = float(cached)
|
||||
if v > 0:
|
||||
return v
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if get_mark_price_fn is not None:
|
||||
try:
|
||||
mp = get_mark_price_fn(sym)
|
||||
if mp is not None and float(mp) > 0:
|
||||
return float(mp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if position_row:
|
||||
mark = None
|
||||
if parse_position_metrics_fn is not None:
|
||||
try:
|
||||
metrics = parse_position_metrics_fn(
|
||||
position_row, order_leverage=order_leverage
|
||||
)
|
||||
if isinstance(metrics, dict) and metrics.get("mark_price") is not None:
|
||||
mark = float(metrics["mark_price"])
|
||||
except Exception:
|
||||
mark = None
|
||||
if mark is None or mark <= 0:
|
||||
try:
|
||||
mp = parse_position_mark_price(position_row)
|
||||
if mp is not None and mp > 0:
|
||||
mark = float(mp)
|
||||
except Exception:
|
||||
mark = None
|
||||
if mark is not None and mark > 0:
|
||||
return mark
|
||||
|
||||
if fallback_entry is not None:
|
||||
try:
|
||||
entry = float(fallback_entry)
|
||||
if entry > 0:
|
||||
return entry
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def seed_prices_from_positions(
|
||||
prices: dict[str, float],
|
||||
order_rows: Sequence[Any],
|
||||
all_positions: Sequence[dict[str, Any]],
|
||||
*,
|
||||
resolve_ex_sym_fn: Callable[[Any], str],
|
||||
) -> None:
|
||||
"""用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐)."""
|
||||
if not all_positions or not order_rows:
|
||||
return
|
||||
try:
|
||||
from lib.hub.hub_symbol_lib import symbols_match
|
||||
except Exception:
|
||||
symbols_match = None
|
||||
for r in order_rows:
|
||||
try:
|
||||
sym = str(r["symbol"] or "").strip()
|
||||
except (KeyError, TypeError, IndexError):
|
||||
sym = ""
|
||||
if not sym or sym in prices:
|
||||
continue
|
||||
try:
|
||||
ex_sym = resolve_ex_sym_fn(r)
|
||||
except Exception:
|
||||
ex_sym = sym
|
||||
try:
|
||||
direction = str(r["direction"] or "long").lower()
|
||||
except (KeyError, TypeError, IndexError):
|
||||
direction = "long"
|
||||
for p in all_positions:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
ps = p.get("symbol") or ""
|
||||
if not ps:
|
||||
continue
|
||||
matched = ps == sym or ps == ex_sym
|
||||
if not matched and symbols_match is not None:
|
||||
matched = symbols_match(sym, ps) or symbols_match(ex_sym, ps)
|
||||
if not matched:
|
||||
continue
|
||||
side = (p.get("side") or "").lower()
|
||||
if side and side != direction:
|
||||
continue
|
||||
mp = parse_position_mark_price(p)
|
||||
if mp is not None and mp > 0:
|
||||
prices[sym] = float(mp)
|
||||
break
|
||||
Reference in New Issue
Block a user