Normalize fullwidth punctuation to ASCII across codebase.
Add scripts/normalize_ambiguous_unicode.py; fix corrupted patch_instance_theme_templates.py. Preserves curly quotes in string literals; removes Git homoglyph warnings on .env.example. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+36
-36
@@ -1,36 +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
|
||||
"""中控调用实例 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控备份与恢复:三所 SQLite、K 线库、env、hub JSON。"""
|
||||
"""中控备份与恢复:三所 SQLite,K 线库,env,hub JSON."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -357,7 +357,7 @@ def restore_backup_archive(
|
||||
zf.extractall(extract_dir)
|
||||
manifest_path = extract_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return {"ok": False, "error": "无效的备份包:缺少 manifest.json"}
|
||||
return {"ok": False, "error": "无效的备份包:缺少 manifest.json"}
|
||||
|
||||
for fp in extract_dir.rglob("*"):
|
||||
if not fp.is_file() or fp.name == "manifest.json":
|
||||
|
||||
+1093
-1093
File diff suppressed because it is too large
Load Diff
+498
-498
@@ -1,498 +1,498 @@
|
||||
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)。"""
|
||||
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_at_tp = (tp - new_avg) * new_qty * cs
|
||||
else:
|
||||
loss_at_sl = (sl - new_avg) * new_qty * cs
|
||||
reward_at_tp = (new_avg - tp) * new_qty * cs
|
||||
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 = (tp - avg) * qty_f * cs
|
||||
else:
|
||||
first_loss = (initial_sl - avg) * qty_f * cs
|
||||
first_profit = (avg - tp) * qty_f * cs
|
||||
|
||||
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
|
||||
"""中控历史测算:趋势回调 / 滚仓,以损定仓(按交易所精度与张数规则)."""
|
||||
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_at_tp = (tp - new_avg) * new_qty * cs
|
||||
else:
|
||||
loss_at_sl = (sl - new_avg) * new_qty * cs
|
||||
reward_at_tp = (new_avg - tp) * new_qty * cs
|
||||
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 = (tp - avg) * qty_f * cs
|
||||
else:
|
||||
first_loss = (initial_sl - avg) * qty_f * cs
|
||||
first_profit = (avg - tp) * qty_f * cs
|
||||
|
||||
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
|
||||
|
||||
@@ -1,257 +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()
|
||||
"""计算器:从已配置交易实例读取 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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振。"""
|
||||
"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -91,7 +91,7 @@ def detect_latest_macd_divergence(
|
||||
align_bars: int = SWING_ALIGN_BARS,
|
||||
recency_bars: int = RECENCY_BARS,
|
||||
) -> dict[str, Any]:
|
||||
"""档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类)。"""
|
||||
"""档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类)."""
|
||||
if len(closes) < swing_lookback * 2 + 10:
|
||||
return {"direction": None}
|
||||
macd = build_macd_by_index(closes)
|
||||
@@ -417,7 +417,7 @@ 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。"""
|
||||
"""对 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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控开仓计划:进行中 / 历史归档 / 胜率统计。"""
|
||||
"""中控开仓计划:进行中 / 历史归档 / 胜率统计."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -338,7 +338,7 @@ def resolve_stats_date_bounds(
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
) -> tuple[str | None, str | None, str]:
|
||||
"""返回 (date_from, date_to, label);all 时 bounds 为 None。"""
|
||||
"""返回 (date_from, date_to, label);all 时 bounds 为 None."""
|
||||
p = (period or "all").strip().lower() or "all"
|
||||
today = _today_iso()
|
||||
if p == "all":
|
||||
|
||||
+437
-437
@@ -1,437 +1,437 @@
|
||||
"""中控资金概况:分户日快照(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
|
||||
|
||||
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 _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 = 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()):
|
||||
ac = (history.get(day) or {}).get("accounts", {}).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
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
"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 "(暂无资金历史快照)"
|
||||
"""中控资金概况:分户日快照(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
|
||||
|
||||
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 _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 = 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()):
|
||||
ac = (history.get(day) or {}).get("accounts", {}).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
|
||||
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)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
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,
|
||||
"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 "(暂无资金历史快照)"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条)。"""
|
||||
"""中控:本机 CPU / 内存 / 磁盘 / 网络快照(监控区服务器状态条)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
@@ -37,7 +37,7 @@ def get_host_status() -> dict[str, Any]:
|
||||
except ImportError:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil",
|
||||
"msg": "未安装 psutil,请在 manual-trading-hub 环境执行 pip install psutil",
|
||||
}
|
||||
|
||||
now = time.time()
|
||||
|
||||
+881
-881
File diff suppressed because it is too large
Load Diff
+311
-311
@@ -1,311 +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),建议等待,避免新开仓"
|
||||
"""中控宏观关键数据日历:手动录入 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),建议等待,避免新开仓"
|
||||
|
||||
@@ -1,81 +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,
|
||||
}
|
||||
|
||||
"""实例 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,
|
||||
}
|
||||
|
||||
|
||||
+111
-111
@@ -1,111 +1,111 @@
|
||||
"""监控区看板:三所当日统计聚合。"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
agent_upnl = _coerce_float(ag.get("total_unrealized_pnl"))
|
||||
if agent_upnl is not None:
|
||||
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),
|
||||
}
|
||||
"""监控区看板:三所当日统计聚合."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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)
|
||||
agent_upnl = _coerce_float(ag.get("total_unrealized_pnl"))
|
||||
if agent_upnl is not None:
|
||||
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),
|
||||
}
|
||||
|
||||
+692
-692
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计。"""
|
||||
"""中控资金统计:期权 USDC/USDT 按 1:1 计入 USDT 合计."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
@@ -23,7 +23,7 @@ def _account_total_usdt(funding: Any, trading: Any) -> Optional[float]:
|
||||
|
||||
|
||||
def stablecoin_usdt_equiv(value: Any) -> Optional[float]:
|
||||
"""USDC / USDT 按 1:1 折算为 USDT 统计口径。"""
|
||||
"""USDC / USDT 按 1:1 折算为 USDT 统计口径."""
|
||||
return _safe_float(value)
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def _sum_optional(*values: Any) -> Optional[float]:
|
||||
|
||||
|
||||
def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""从期权 snapshot 提取资金户/交易户 USDT 等价余额。"""
|
||||
"""从期权 snapshot 提取资金户/交易户 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}
|
||||
@@ -80,7 +80,7 @@ def merge_perp_options_balances(
|
||||
perpetual_trading_usdt: Any,
|
||||
options_snap: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""永续 + 期权余额合并为中控 USDT 统计口径。"""
|
||||
"""永续 + 期权余额合并为中控 USDT 统计口径."""
|
||||
opt = options_balances_usdt_equiv(options_snap)
|
||||
funding = _sum_optional(perpetual_funding_usdt, opt.get("funding_usdt"))
|
||||
trading = _sum_optional(perpetual_trading_usdt, opt.get("trading_usdt"))
|
||||
@@ -104,7 +104,7 @@ def merge_perp_options_balances(
|
||||
|
||||
|
||||
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(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单)。"""
|
||||
"""中控改委托后同步实例 order_monitors 计划价(交易所已由 agent 挂单)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
@@ -14,7 +14,7 @@ def cond_order_role(row: dict[str, Any]) -> str | None:
|
||||
|
||||
|
||||
def dedupe_conditional_orders_by_role(orders: list) -> list:
|
||||
"""同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复)。"""
|
||||
"""同一持仓条件单列表:每种止盈/止损只保留一条(避免 OKX OCO 拆分 + Flask 补全重复)."""
|
||||
if not orders:
|
||||
return []
|
||||
by_role: dict[str, dict] = {}
|
||||
@@ -35,7 +35,7 @@ def dedupe_conditional_orders_by_role(orders: list) -> list:
|
||||
|
||||
|
||||
def exchange_tpsl_from_cond_orders(cond: list) -> dict[str, Any] | None:
|
||||
"""从子代理条件单列表还原 exchange_tpsl 槽位。"""
|
||||
"""从子代理条件单列表还原 exchange_tpsl 槽位."""
|
||||
slots: dict[str, Any] = {"sl": None, "tp": None}
|
||||
for row in cond or []:
|
||||
if not isinstance(row, dict):
|
||||
@@ -72,7 +72,7 @@ def sync_active_monitor_tpsl_prices(
|
||||
*,
|
||||
symbols_match: Callable[[str, str], bool],
|
||||
) -> dict[str, Any]:
|
||||
"""按 symbol+方向更新 active 下单监控的 stop_loss / take_profit。"""
|
||||
"""按 symbol+方向更新 active 下单监控的 stop_loss / take_profit."""
|
||||
sym = (symbol or "").strip()
|
||||
side = (direction or "").strip().lower()
|
||||
if not sym:
|
||||
@@ -85,7 +85,7 @@ def sync_active_monitor_tpsl_prices(
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "msg": "stop_loss / take_profit 须为数字"}
|
||||
if sl <= 0 or tp <= 0:
|
||||
return {"ok": False, "msg": "止损、止盈须大于 0"}
|
||||
return {"ok": False, "msg": "止损,止盈须大于 0"}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, symbol, exchange_symbol, direction FROM order_monitors WHERE status='active'"
|
||||
|
||||
+270
-270
@@ -1,270 +1,270 @@
|
||||
"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
"""ccxt 持仓标记价解析(实例 price_snapshot 与中控子代理共用)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -1,95 +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}
|
||||
"""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}
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN)。无 Flask 依赖,供中控 FastAPI 与各实例共用。
|
||||
实例浏览器 SSO(复用 HUB_BRIDGE_TOKEN).无 Flask 依赖,供中控 FastAPI 与各实例共用.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -109,7 +109,7 @@ def verify_hub_sso_token(
|
||||
|
||||
|
||||
def mint_hub_embed_bootstrap(exchange_key: str, next_path: str = "/") -> str | None:
|
||||
"""iframe 内嵌登录引导 token(短效、单次),供 /hub-embed-auth 写入 SameSite=None Cookie。"""
|
||||
"""iframe 内嵌登录引导 token(短效,单次),供 /hub-embed-auth 写入 SameSite=None Cookie."""
|
||||
secret = _sso_secret()
|
||||
ex = (exchange_key or "").strip().lower()
|
||||
if not secret or not ex:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""中控「策略说明」:读取 docs/strategy MD + checklists JSON。"""
|
||||
"""中控「策略说明」:读取 docs/strategy MD + checklists JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -47,7 +47,7 @@ def _md_path(exchange_key: str) -> Path:
|
||||
|
||||
|
||||
def _parse_version(md_text: str) -> str:
|
||||
m = re.search(r">\s*\*\*状态\*\*[::]\s*(v[\d.]+)", md_text)
|
||||
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)
|
||||
@@ -409,7 +409,7 @@ def build_print_html(exchange_key: str, part: str = "doc") -> str:
|
||||
<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>
|
||||
<footer class="doc-foot">文档:{escape_html(str(source))}{(" · " + escape_html(version)) if version else ""}</footer>
|
||||
</article>"""
|
||||
else:
|
||||
raise KeyError(part)
|
||||
|
||||
+1718
-1718
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""合约 symbol 匹配(持仓 vs 监控/挂单)。"""
|
||||
"""合约 symbol 匹配(持仓 vs 监控/挂单)."""
|
||||
|
||||
|
||||
def _symbol_base_coin(symbol: str) -> str:
|
||||
|
||||
+742
-742
File diff suppressed because it is too large
Load Diff
+595
-595
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices。"""
|
||||
"""price_snapshot 共用:订单行情价兜底,避免 get_price 失败时整单不入 order_prices."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Mapping, Optional, Sequence
|
||||
@@ -17,10 +17,10 @@ def resolve_order_snapshot_price(
|
||||
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)
|
||||
2. get_symbol_mark_price(含 mark)
|
||||
3. 交易所持仓 mark(parse_ccxt_position_metrics / parse_position_mark_price)
|
||||
4. 计划成交价 trigger_price
|
||||
"""
|
||||
sym = (symbol or "").strip()
|
||||
@@ -82,7 +82,7 @@ def seed_prices_from_positions(
|
||||
*,
|
||||
resolve_ex_sym_fn: Callable[[Any], str],
|
||||
) -> None:
|
||||
"""用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐)。"""
|
||||
"""用持仓标记价补全 prices 字典(symbol 与 order_monitors 行对齐)."""
|
||||
if not all_positions or not order_rows:
|
||||
return
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user