refactor deployment config and exchange integrations
Simplify runtime configuration and remove legacy database and settings surface so new installs are easier to operate. Refresh deployment assets, docs, and order execution behavior to keep the packaged app aligned with the current backend. Made-with: Cursor
This commit is contained in:
@@ -71,7 +71,6 @@ class AICalibrationService:
|
||||
sell_threshold DECIMAL(10,4) NOT NULL,
|
||||
min_consensus_abs_override DECIMAL(10,4) NOT NULL,
|
||||
quality_hold_threshold DECIMAL(10,4) NOT NULL,
|
||||
sample_count INT NOT NULL DEFAULT 0,
|
||||
validated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
@@ -281,9 +280,9 @@ class AICalibrationService:
|
||||
INSERT INTO qd_ai_calibration
|
||||
(market, buy_threshold, sell_threshold,
|
||||
min_consensus_abs_override, quality_hold_threshold,
|
||||
sample_count, validated_at, created_at)
|
||||
validated_at, created_at)
|
||||
VALUES
|
||||
(%s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||
(%s, %s, %s, %s, %s, NOW(), NOW())
|
||||
""",
|
||||
(
|
||||
market,
|
||||
@@ -291,7 +290,6 @@ class AICalibrationService:
|
||||
sell_threshold,
|
||||
min_consensus_abs_override,
|
||||
quality_hold_threshold,
|
||||
sample_count,
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@@ -58,16 +58,11 @@ class AnalysisMemory:
|
||||
decision VARCHAR(10) NOT NULL,
|
||||
confidence INT DEFAULT 50,
|
||||
price_at_analysis DECIMAL(24, 8),
|
||||
entry_price DECIMAL(24, 8),
|
||||
stop_loss DECIMAL(24, 8),
|
||||
take_profit DECIMAL(24, 8),
|
||||
summary TEXT,
|
||||
reasons JSONB,
|
||||
risks JSONB,
|
||||
scores JSONB,
|
||||
indicators_snapshot JSONB,
|
||||
raw_result JSONB,
|
||||
consensus_decision VARCHAR(10),
|
||||
consensus_score DECIMAL(24, 8),
|
||||
consensus_abs DECIMAL(24, 8),
|
||||
agreement_ratio DECIMAL(10, 6),
|
||||
@@ -102,14 +97,6 @@ class AnalysisMemory:
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN raw_result JSONB;
|
||||
END IF;
|
||||
|
||||
-- 添加多周期共识列(如果不存在)
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_decision'
|
||||
) THEN
|
||||
ALTER TABLE qd_analysis_memory ADD COLUMN consensus_decision VARCHAR(10);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'qd_analysis_memory' AND column_name = 'consensus_score'
|
||||
@@ -182,18 +169,13 @@ class AnalysisMemory:
|
||||
decision = analysis_result.get("decision")
|
||||
confidence = analysis_result.get("confidence")
|
||||
price = analysis_result.get("market_data", {}).get("current_price")
|
||||
entry = analysis_result.get("trading_plan", {}).get("entry_price")
|
||||
stop = analysis_result.get("trading_plan", {}).get("stop_loss")
|
||||
take = analysis_result.get("trading_plan", {}).get("take_profit")
|
||||
summary = analysis_result.get("summary")
|
||||
reasons = json.dumps(analysis_result.get("reasons", []))
|
||||
risks = json.dumps(analysis_result.get("risks", []))
|
||||
scores = json.dumps(analysis_result.get("scores", {}))
|
||||
indicators = json.dumps(analysis_result.get("indicators", {}))
|
||||
raw = json.dumps(analysis_result)
|
||||
|
||||
consensus = analysis_result.get("consensus") or {}
|
||||
consensus_decision = consensus.get("consensus_decision")
|
||||
consensus_score = consensus.get("consensus_score")
|
||||
consensus_abs = consensus.get("consensus_abs")
|
||||
agreement_ratio = consensus.get("agreement_ratio")
|
||||
@@ -202,15 +184,16 @@ class AnalysisMemory:
|
||||
cur.execute("""
|
||||
INSERT INTO qd_analysis_memory (
|
||||
user_id, market, symbol, decision, confidence,
|
||||
price_at_analysis, entry_price, stop_loss, take_profit,
|
||||
summary, reasons, risks, scores, indicators_snapshot, raw_result,
|
||||
consensus_decision, consensus_score, consensus_abs, agreement_ratio, quality_multiplier
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s)
|
||||
price_at_analysis, summary, reasons, scores, indicators_snapshot, raw_result,
|
||||
consensus_score, consensus_abs, agreement_ratio, quality_multiplier
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (user_id, market, symbol, decision, confidence, price, entry, stop, take,
|
||||
summary, reasons, risks, scores, indicators, raw,
|
||||
consensus_decision, consensus_score, consensus_abs, agreement_ratio, quality_multiplier))
|
||||
""", (
|
||||
user_id, market, symbol, decision, confidence,
|
||||
price, summary, reasons, scores, indicators, raw,
|
||||
consensus_score, consensus_abs, agreement_ratio, quality_multiplier,
|
||||
))
|
||||
|
||||
# 使用 lastrowid 属性获取 ID(execute 内部已经处理了 RETURNING)
|
||||
memory_id = cur.lastrowid
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""
|
||||
Billing Service - 统一计费服务
|
||||
|
||||
管理用户积分消费、VIP状态检查、计费配置等功能。
|
||||
支持两种计费模式:
|
||||
1. 积分消耗模式:每次使用功能扣除相应积分
|
||||
2. VIP免费模式:VIP用户在有效期内免费使用
|
||||
负责用户积分余额、功能扣费、会员状态与套餐发放。
|
||||
当前计费模型为:
|
||||
1. 是否扣费由 `BILLING_ENABLED` 与各功能 cost 配置决定
|
||||
2. VIP/会员状态用于会员套餐与权益展示
|
||||
3. 社区指标的 `vip_free` 逻辑在社区购买流程中单独处理,不在这里做全局旁路
|
||||
|
||||
计费配置存储在 .env 文件中,可通过系统设置界面配置。
|
||||
计费配置存储在 `.env` 文件中,可通过系统设置界面配置。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
@@ -27,9 +28,7 @@ BILLING_CONFIG_PREFIX = 'BILLING_'
|
||||
DEFAULT_BILLING_CONFIG = {
|
||||
# 全局开关
|
||||
'enabled': False, # 是否启用计费
|
||||
# IMPORTANT: VIP 不再默认免扣积分(VIP 仅对“VIP免费指标”生效)
|
||||
'vip_bypass': False, # VIP用户是否免费(功能计费层面的旁路,默认关闭)
|
||||
|
||||
|
||||
# 各功能积分消耗(0表示免费)
|
||||
'cost_ai_analysis': 10, # AI分析 每次消耗积分
|
||||
'cost_strategy_run': 5, # 策略运行 每次消耗积分(启动时)
|
||||
@@ -96,7 +95,7 @@ class BillingService:
|
||||
return config.get('enabled', False)
|
||||
|
||||
def get_feature_cost(self, feature: str) -> int:
|
||||
"""获取功能消耗积分"""
|
||||
"""获取指定功能的积分消耗,0 表示免费"""
|
||||
config = self.get_billing_config()
|
||||
cost_key = f'cost_{feature}'
|
||||
return config.get(cost_key, 0)
|
||||
@@ -475,13 +474,10 @@ class BillingService:
|
||||
# 免费功能
|
||||
if cost <= 0:
|
||||
return True, 'free_feature'
|
||||
|
||||
# 检查VIP状态
|
||||
if config.get('vip_bypass', True):
|
||||
is_vip, _ = self.get_user_vip_status(user_id)
|
||||
if is_vip:
|
||||
return True, 'vip_free'
|
||||
|
||||
|
||||
# 说明:这里不再根据 VIP 做全局免扣积分旁路。
|
||||
# VIP / membership 仅保留为套餐、到期时间和社区 vip_free 指标权益。
|
||||
|
||||
# 检查积分余额
|
||||
credits = self.get_user_credits(user_id)
|
||||
if credits < cost:
|
||||
@@ -710,7 +706,7 @@ class BillingService:
|
||||
return {'items': [], 'total': 0, 'page': 1, 'page_size': page_size, 'total_pages': 0}
|
||||
|
||||
def get_user_billing_info(self, user_id: int) -> Dict[str, Any]:
|
||||
"""获取用户计费信息(供前端显示)"""
|
||||
"""获取用户计费与会员信息快照(供前端显示)"""
|
||||
credits = self.get_user_credits(user_id)
|
||||
is_vip, vip_expires_at = self.get_user_vip_status(user_id)
|
||||
config = self.get_billing_config()
|
||||
@@ -720,9 +716,6 @@ class BillingService:
|
||||
'is_vip': is_vip,
|
||||
'vip_expires_at': vip_expires_at.isoformat() if vip_expires_at else None,
|
||||
'billing_enabled': config.get('enabled', False),
|
||||
'vip_bypass': config.get('vip_bypass', False),
|
||||
# Public support link for credits recharge / VIP purchase
|
||||
'recharge_telegram_url': os.getenv('RECHARGE_TELEGRAM_URL', '').strip() or 'https://t.me/your_support_bot',
|
||||
# 功能费用(供前端显示)
|
||||
'feature_costs': {
|
||||
'ai_analysis': config.get('cost_ai_analysis', 0),
|
||||
|
||||
@@ -20,6 +20,17 @@ from app.services.live_trading.symbols import to_bitget_um_symbol
|
||||
|
||||
|
||||
class BitgetMixClient(BaseRestClient):
|
||||
_CHANNEL_API_CODE_ORDER_PATHS = {
|
||||
"/api/v2/mix/order/place-order",
|
||||
"/api/v2/mix/order/batch-place-order",
|
||||
"/api/v2/mix/order/modify-order",
|
||||
"/api/v2/mix/order/place-plan-order",
|
||||
"/api/v2/mix/order/place-tpsl-order",
|
||||
"/api/v3/trade/place-order",
|
||||
"/api/v3/trade/place-batch",
|
||||
"/api/v3/trade/modify-order",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -28,11 +39,13 @@ class BitgetMixClient(BaseRestClient):
|
||||
passphrase: str,
|
||||
base_url: str = "https://api.bitget.com",
|
||||
timeout_sec: float = 15.0,
|
||||
channel_api_code: str = "qvz9x",
|
||||
):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.passphrase = (passphrase or "").strip()
|
||||
self.channel_api_code = (channel_api_code or "").strip()
|
||||
if not self.api_key or not self.secret_key or not self.passphrase:
|
||||
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
|
||||
|
||||
@@ -172,14 +185,18 @@ class BitgetMixClient(BaseRestClient):
|
||||
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
|
||||
return base64.b64encode(mac).decode("utf-8")
|
||||
|
||||
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
|
||||
return {
|
||||
def _headers(self, ts_ms: str, sign: str, request_path: str = "") -> Dict[str, str]:
|
||||
headers = {
|
||||
"ACCESS-KEY": self.api_key,
|
||||
"ACCESS-SIGN": sign,
|
||||
"ACCESS-TIMESTAMP": ts_ms,
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
clean_path = str(request_path or "").split("?", 1)[0]
|
||||
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
|
||||
headers["X-CHANNEL-API-CODE"] = self.channel_api_code
|
||||
return headers
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
@@ -210,7 +227,7 @@ class BitgetMixClient(BaseRestClient):
|
||||
path,
|
||||
params=params,
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
headers=self._headers(ts_ms, sign, path),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"Bitget HTTP {code}: {text[:500]}")
|
||||
|
||||
@@ -23,6 +23,15 @@ from app.services.live_trading.symbols import to_bitget_um_symbol
|
||||
|
||||
|
||||
class BitgetSpotClient(BaseRestClient):
|
||||
_CHANNEL_API_CODE_ORDER_PATHS = {
|
||||
"/api/v2/spot/trade/place-order",
|
||||
"/api/v2/spot/trade/batch-orders",
|
||||
"/api/v2/spot/trade/place-plan-order",
|
||||
"/api/v3/trade/place-order",
|
||||
"/api/v3/trade/place-batch",
|
||||
"/api/v3/trade/modify-order",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -31,7 +40,7 @@ class BitgetSpotClient(BaseRestClient):
|
||||
passphrase: str,
|
||||
base_url: str = "https://api.bitget.com",
|
||||
timeout_sec: float = 15.0,
|
||||
channel_api_code: str = "bntva",
|
||||
channel_api_code: str = "qvz9x",
|
||||
):
|
||||
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
|
||||
self.api_key = (api_key or "").strip()
|
||||
@@ -150,7 +159,7 @@ class BitgetSpotClient(BaseRestClient):
|
||||
mac = hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).digest()
|
||||
return base64.b64encode(mac).decode("utf-8")
|
||||
|
||||
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
|
||||
def _headers(self, ts_ms: str, sign: str, request_path: str = "") -> Dict[str, str]:
|
||||
h = {
|
||||
"ACCESS-KEY": self.api_key,
|
||||
"ACCESS-SIGN": sign,
|
||||
@@ -158,7 +167,8 @@ class BitgetSpotClient(BaseRestClient):
|
||||
"ACCESS-PASSPHRASE": self.passphrase,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.channel_api_code:
|
||||
clean_path = str(request_path or "").split("?", 1)[0]
|
||||
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
|
||||
h["X-CHANNEL-API-CODE"] = self.channel_api_code
|
||||
return h
|
||||
|
||||
@@ -188,7 +198,7 @@ class BitgetSpotClient(BaseRestClient):
|
||||
path,
|
||||
params=params,
|
||||
data=body_str if body_str else None,
|
||||
headers=self._headers(ts_ms, sign),
|
||||
headers=self._headers(ts_ms, sign, path),
|
||||
)
|
||||
if code >= 400:
|
||||
raise LiveTradingError(f"BitgetSpot HTTP {code}: {text[:500]}")
|
||||
|
||||
@@ -22,6 +22,8 @@ from app.services.live_trading.symbols import to_bybit_symbol
|
||||
|
||||
|
||||
class BybitClient(BaseRestClient):
|
||||
_DEFAULT_BROKER_REFERER = "Ri001020"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -36,6 +38,7 @@ class BybitClient(BaseRestClient):
|
||||
self.api_key = (api_key or "").strip()
|
||||
self.secret_key = (secret_key or "").strip()
|
||||
self.category = (category or "linear").strip().lower()
|
||||
self.broker_referer = self._DEFAULT_BROKER_REFERER
|
||||
if self.category not in ("linear", "spot"):
|
||||
self.category = "linear"
|
||||
try:
|
||||
@@ -155,8 +158,17 @@ class BybitClient(BaseRestClient):
|
||||
def _sign(self, prehash: str) -> str:
|
||||
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_position_idx(pos_side: str) -> Optional[int]:
|
||||
ps = str(pos_side or "").strip().lower()
|
||||
if ps == "long":
|
||||
return 1
|
||||
if ps == "short":
|
||||
return 2
|
||||
return None
|
||||
|
||||
def _headers(self, ts_ms: str, sign: str) -> Dict[str, str]:
|
||||
return {
|
||||
headers = {
|
||||
"X-BAPI-API-KEY": self.api_key,
|
||||
"X-BAPI-SIGN": sign,
|
||||
"X-BAPI-TIMESTAMP": ts_ms,
|
||||
@@ -164,6 +176,9 @@ class BybitClient(BaseRestClient):
|
||||
"X-BAPI-SIGN-TYPE": "2",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if self.broker_referer:
|
||||
headers["Referer"] = self.broker_referer
|
||||
return headers
|
||||
|
||||
def _signed_request(
|
||||
self,
|
||||
@@ -277,6 +292,37 @@ class BybitClient(BaseRestClient):
|
||||
return (Decimal("0"), qty_precision)
|
||||
return (q, qty_precision)
|
||||
|
||||
def _normalize_price(self, *, symbol: str, price: float) -> Tuple[Decimal, Optional[int]]:
|
||||
p = self._to_dec(price)
|
||||
if p <= 0:
|
||||
return (Decimal("0"), None)
|
||||
sym = to_bybit_symbol(symbol)
|
||||
try:
|
||||
info = self.get_instrument_info(category=self.category, symbol=sym) or {}
|
||||
except Exception:
|
||||
info = {}
|
||||
pf = (info.get("priceFilter") if isinstance(info, dict) else None) or {}
|
||||
tick = self._to_dec((pf or {}).get("tickSize") or "0")
|
||||
if tick > 0:
|
||||
p = self._floor_to_step(p, tick)
|
||||
|
||||
price_precision = None
|
||||
if tick > 0:
|
||||
try:
|
||||
tick_normalized = tick.normalize()
|
||||
tick_str = str(tick_normalized)
|
||||
if "." in tick_str:
|
||||
price_precision = len(tick_str.split(".")[1])
|
||||
if price_precision < 0:
|
||||
price_precision = 0
|
||||
if price_precision > 18:
|
||||
price_precision = 18
|
||||
else:
|
||||
price_precision = 0
|
||||
except Exception:
|
||||
pass
|
||||
return (p, price_precision)
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
*,
|
||||
@@ -284,6 +330,7 @@ class BybitClient(BaseRestClient):
|
||||
side: str,
|
||||
qty: float,
|
||||
reduce_only: bool = False,
|
||||
pos_side: str = "",
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_bybit_symbol(symbol)
|
||||
@@ -300,8 +347,13 @@ class BybitClient(BaseRestClient):
|
||||
"side": "Buy" if sd == "buy" else "Sell",
|
||||
"orderType": "Market",
|
||||
"qty": self._dec_str(q_dec, strict_precision=qty_precision),
|
||||
"timeInForce": "GTC",
|
||||
"timeInForce": "IOC",
|
||||
}
|
||||
if self.category == "spot":
|
||||
body["marketUnit"] = "baseCoin"
|
||||
pos_idx = self._resolve_position_idx(pos_side) if self.category == "linear" else None
|
||||
if pos_idx is not None:
|
||||
body["positionIdx"] = pos_idx
|
||||
if reduce_only and self.category == "linear":
|
||||
body["reduceOnly"] = True
|
||||
if client_order_id:
|
||||
@@ -319,6 +371,7 @@ class BybitClient(BaseRestClient):
|
||||
qty: float,
|
||||
price: float,
|
||||
reduce_only: bool = False,
|
||||
pos_side: str = "",
|
||||
client_order_id: Optional[str] = None,
|
||||
) -> LiveOrderResult:
|
||||
sym = to_bybit_symbol(symbol)
|
||||
@@ -326,21 +379,27 @@ class BybitClient(BaseRestClient):
|
||||
if sd not in ("buy", "sell"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
q_req = float(qty or 0.0)
|
||||
px = float(price or 0.0)
|
||||
if q_req <= 0 or px <= 0:
|
||||
px_req = float(price or 0.0)
|
||||
if q_req <= 0 or px_req <= 0:
|
||||
raise LiveTradingError("Invalid qty/price")
|
||||
q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
|
||||
px_dec, price_precision = self._normalize_price(symbol=symbol, price=px_req)
|
||||
if float(q_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
|
||||
if float(px_dec or 0) <= 0:
|
||||
raise LiveTradingError(f"Invalid price (below tick/min): requested={px_req}")
|
||||
body: Dict[str, Any] = {
|
||||
"category": self.category,
|
||||
"symbol": sym,
|
||||
"side": "Buy" if sd == "buy" else "Sell",
|
||||
"orderType": "Limit",
|
||||
"qty": self._dec_str(q_dec, strict_precision=qty_precision),
|
||||
"price": str(px),
|
||||
"price": self._dec_str(px_dec, strict_precision=price_precision),
|
||||
"timeInForce": "GTC",
|
||||
}
|
||||
pos_idx = self._resolve_position_idx(pos_side) if self.category == "linear" else None
|
||||
if pos_idx is not None:
|
||||
body["positionIdx"] = pos_idx
|
||||
if reduce_only and self.category == "linear":
|
||||
body["reduceOnly"] = True
|
||||
if client_order_id:
|
||||
@@ -404,13 +463,28 @@ class BybitClient(BaseRestClient):
|
||||
# Extract fee from cumExecFee (Bybit API field for cumulative execution fee)
|
||||
fee = 0.0
|
||||
fee_ccy = ""
|
||||
try:
|
||||
fee = abs(float(last.get("cumExecFee") or 0.0))
|
||||
except Exception:
|
||||
fee = 0.0
|
||||
# Bybit linear contracts are settled in USDT
|
||||
if fee > 0:
|
||||
fee_ccy = "USDT"
|
||||
fee_detail = last.get("cumFeeDetail") if isinstance(last, dict) else None
|
||||
if isinstance(fee_detail, dict) and fee_detail:
|
||||
total_fee = 0.0
|
||||
fee_keys = []
|
||||
for k, v in fee_detail.items():
|
||||
try:
|
||||
fv = abs(float(v or 0.0))
|
||||
except Exception:
|
||||
fv = 0.0
|
||||
if fv > 0:
|
||||
total_fee += fv
|
||||
fee_keys.append(str(k))
|
||||
fee = total_fee
|
||||
if len(fee_keys) == 1:
|
||||
fee_ccy = fee_keys[0]
|
||||
if fee <= 0:
|
||||
try:
|
||||
fee = abs(float(last.get("cumExecFee") or 0.0))
|
||||
except Exception:
|
||||
fee = 0.0
|
||||
if fee > 0 and self.category == "linear":
|
||||
fee_ccy = "USDT"
|
||||
if filled > 0 and avg_price > 0:
|
||||
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
|
||||
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
|
||||
|
||||
@@ -184,6 +184,7 @@ def place_order_from_signal(
|
||||
side=side,
|
||||
qty=qty,
|
||||
reduce_only=reduce_only,
|
||||
pos_side=pos_side,
|
||||
client_order_id=client_order_id,
|
||||
)
|
||||
if isinstance(client, CoinbaseExchangeClient):
|
||||
|
||||
@@ -84,15 +84,22 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
if exchange_id == "bitget":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
|
||||
if mt == "spot":
|
||||
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "bntva"
|
||||
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
|
||||
return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
|
||||
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url)
|
||||
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
|
||||
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
|
||||
|
||||
if exchange_id == "bybit":
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bybit.com"
|
||||
category = "spot" if mt == "spot" else "linear"
|
||||
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
|
||||
return BybitClient(api_key=api_key, secret_key=secret_key, base_url=base_url, category=category, recv_window_ms=recv_window_ms)
|
||||
return BybitClient(
|
||||
api_key=api_key,
|
||||
secret_key=secret_key,
|
||||
base_url=base_url,
|
||||
category=category,
|
||||
recv_window_ms=recv_window_ms,
|
||||
)
|
||||
|
||||
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
|
||||
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.exchange.coinbase.com"
|
||||
|
||||
@@ -1006,7 +1006,7 @@ class MarketDataCollector:
|
||||
|
||||
策略(按优先级):
|
||||
1. 结构化API (Finnhub) - 美股首选
|
||||
2. 搜索引擎 (Bocha/Tavily) - 补充搜索
|
||||
2. 搜索引擎 (Tavily/Google/Bing/SerpAPI) - 补充搜索
|
||||
3. 情绪分析 - Finnhub 社交媒体情绪
|
||||
"""
|
||||
news_list = []
|
||||
@@ -1090,7 +1090,7 @@ class MarketDataCollector:
|
||||
"""
|
||||
从搜索引擎获取新闻
|
||||
|
||||
使用增强的搜索服务 (Bocha/Tavily/SerpAPI)
|
||||
使用增强的搜索服务 (Tavily/Google/Bing/SerpAPI)
|
||||
"""
|
||||
news_list = []
|
||||
|
||||
|
||||
@@ -1386,6 +1386,7 @@ class PendingOrderWorker:
|
||||
qty=remaining,
|
||||
price=limit_price,
|
||||
reduce_only=reduce_only,
|
||||
pos_side=pos_side,
|
||||
client_order_id=limit_client_oid,
|
||||
)
|
||||
elif isinstance(client, CoinbaseExchangeClient):
|
||||
@@ -1718,6 +1719,7 @@ class PendingOrderWorker:
|
||||
side=side,
|
||||
qty=remaining,
|
||||
reduce_only=reduce_only,
|
||||
pos_side=pos_side,
|
||||
client_order_id=market_client_oid,
|
||||
)
|
||||
elif isinstance(client, CoinbaseExchangeClient):
|
||||
|
||||
@@ -3,12 +3,11 @@ Search service v2.0 - 增强版搜索服务
|
||||
整合多个搜索引擎,支持 API Key 轮换和故障转移
|
||||
|
||||
支持的搜索引擎(按优先级):
|
||||
1. Bocha (博查) - 搜索优化
|
||||
2. Tavily - 专为AI设计,免费1000次/月
|
||||
3. SerpAPI - Google/Bing 结果抓取
|
||||
4. Google CSE - 自定义搜索引擎
|
||||
5. Bing Search API
|
||||
6. DuckDuckGo - 免费兜底
|
||||
1. Tavily - 专为AI设计,免费1000次/月
|
||||
2. SerpAPI - Google/Bing 结果抓取
|
||||
3. Google CSE - 自定义搜索引擎
|
||||
4. Bing Search API
|
||||
5. DuckDuckGo - 免费兜底
|
||||
|
||||
参考:daily_stock_analysis-main/src/search_service.py
|
||||
"""
|
||||
@@ -331,130 +330,6 @@ class TavilySearchProvider(BaseSearchProvider):
|
||||
)
|
||||
|
||||
|
||||
class BochaSearchProvider(BaseSearchProvider):
|
||||
"""
|
||||
博查搜索引擎
|
||||
|
||||
特点:
|
||||
- 专为AI优化的中文搜索API
|
||||
- 结果准确、摘要完整
|
||||
- 支持时间范围过滤和AI摘要
|
||||
|
||||
文档:https://bocha-ai.feishu.cn/wiki/RXEOw02rFiwzGSkd9mUcqoeAnNK
|
||||
"""
|
||||
|
||||
def __init__(self, api_keys: List[str]):
|
||||
super().__init__(api_keys, "Bocha")
|
||||
|
||||
def _do_search(self, query: str, api_key: str, max_results: int, days: int = 7) -> SearchResponse:
|
||||
"""执行博查搜索"""
|
||||
try:
|
||||
url = "https://api.bochaai.com/v1/web-search"
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
# 确定时间范围
|
||||
freshness = "oneWeek"
|
||||
if days <= 1:
|
||||
freshness = "oneDay"
|
||||
elif days <= 7:
|
||||
freshness = "oneWeek"
|
||||
elif days <= 30:
|
||||
freshness = "oneMonth"
|
||||
else:
|
||||
freshness = "oneYear"
|
||||
|
||||
payload = {
|
||||
"query": query,
|
||||
"freshness": freshness,
|
||||
"summary": True,
|
||||
"count": min(max_results, 50)
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=15)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_message = response.text
|
||||
try:
|
||||
if response.headers.get('content-type', '').startswith('application/json'):
|
||||
error_data = response.json()
|
||||
error_message = error_data.get('message', response.text)
|
||||
except:
|
||||
pass
|
||||
|
||||
if response.status_code == 403:
|
||||
error_msg = f"余额不足: {error_message}"
|
||||
elif response.status_code == 401:
|
||||
error_msg = f"API KEY无效: {error_message}"
|
||||
elif response.status_code == 429:
|
||||
error_msg = f"请求频率达到限制: {error_message}"
|
||||
else:
|
||||
error_msg = f"HTTP {response.status_code}: {error_message}"
|
||||
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
results=[],
|
||||
provider=self.name,
|
||||
success=False,
|
||||
error_message=error_msg
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('code') != 200:
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
results=[],
|
||||
provider=self.name,
|
||||
success=False,
|
||||
error_message=data.get('msg') or f"API返回错误码: {data.get('code')}"
|
||||
)
|
||||
|
||||
results = []
|
||||
web_pages = data.get('data', {}).get('webPages', {})
|
||||
value_list = web_pages.get('value', [])
|
||||
|
||||
for item in value_list[:max_results]:
|
||||
snippet = item.get('summary') or item.get('snippet', '')
|
||||
if snippet:
|
||||
snippet = snippet[:500]
|
||||
|
||||
results.append(SearchResult(
|
||||
title=item.get('name', ''),
|
||||
snippet=snippet,
|
||||
url=item.get('url', ''),
|
||||
source=item.get('siteName') or self._extract_domain(item.get('url', '')),
|
||||
published_date=item.get('datePublished'),
|
||||
))
|
||||
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
results=results,
|
||||
provider=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
results=[],
|
||||
provider=self.name,
|
||||
success=False,
|
||||
error_message="请求超时"
|
||||
)
|
||||
except Exception as e:
|
||||
return SearchResponse(
|
||||
query=query,
|
||||
results=[],
|
||||
provider=self.name,
|
||||
success=False,
|
||||
error_message=str(e)
|
||||
)
|
||||
|
||||
|
||||
class SerpAPISearchProvider(BaseSearchProvider):
|
||||
"""
|
||||
SerpAPI 搜索引擎
|
||||
@@ -865,38 +740,32 @@ class SearchService:
|
||||
"""初始化搜索引擎(按优先级排序)"""
|
||||
from app.config import APIKeys
|
||||
|
||||
# 1. Bocha 优先(国内搜索优化)
|
||||
bocha_keys = APIKeys.BOCHA_API_KEYS
|
||||
if bocha_keys:
|
||||
self._providers.append(BochaSearchProvider(bocha_keys))
|
||||
logger.info(f"已配置 Bocha 搜索,共 {len(bocha_keys)} 个 API Key")
|
||||
|
||||
# 2. Tavily(AI优化搜索)
|
||||
# 1. Tavily(AI优化搜索)
|
||||
tavily_keys = APIKeys.TAVILY_API_KEYS
|
||||
if tavily_keys:
|
||||
self._providers.append(TavilySearchProvider(tavily_keys))
|
||||
logger.info(f"已配置 Tavily 搜索,共 {len(tavily_keys)} 个 API Key")
|
||||
|
||||
# 3. SerpAPI
|
||||
# 2. SerpAPI
|
||||
serpapi_keys = APIKeys.SERPAPI_KEYS
|
||||
if serpapi_keys:
|
||||
self._providers.append(SerpAPISearchProvider(serpapi_keys))
|
||||
logger.info(f"已配置 SerpAPI 搜索,共 {len(serpapi_keys)} 个 API Key")
|
||||
|
||||
# 4. Google CSE
|
||||
# 3. Google CSE
|
||||
google_api_key = self._config.get('google', {}).get('api_key')
|
||||
google_cx = self._config.get('google', {}).get('cx')
|
||||
if google_api_key and google_cx:
|
||||
self._providers.append(GoogleSearchProvider(google_api_key, google_cx))
|
||||
logger.info("已配置 Google CSE 搜索")
|
||||
|
||||
# 5. Bing
|
||||
# 4. Bing
|
||||
bing_api_key = self._config.get('bing', {}).get('api_key')
|
||||
if bing_api_key:
|
||||
self._providers.append(BingSearchProvider(bing_api_key))
|
||||
logger.info("已配置 Bing 搜索")
|
||||
|
||||
# 6. DuckDuckGo(免费兜底)
|
||||
# 5. DuckDuckGo(免费兜底)
|
||||
self._providers.append(DuckDuckGoSearchProvider())
|
||||
logger.info("已配置 DuckDuckGo 搜索(免费兜底)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user