V2.2.1: Membership & Billing, USDT TRC20 payment, VIP free indicators, AI Trading Radar, simplified strategy creation, system settings simplification, bug fixes and UI improvements

This commit is contained in:
TIANHE
2026-02-27 01:57:04 +08:00
parent ae82cc0d4e
commit ffdd2ffbae
71 changed files with 4067 additions and 6073 deletions
@@ -10,7 +10,7 @@ Billing Service - 统一计费服务
"""
import os
import time
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Dict, Any, Optional, Tuple
@@ -27,7 +27,8 @@ BILLING_CONFIG_PREFIX = 'BILLING_'
DEFAULT_BILLING_CONFIG = {
# 全局开关
'enabled': False, # 是否启用计费
'vip_bypass': True, # VIP用户是否免费
# IMPORTANT: VIP 不再默认免扣积分(VIP 仅对“VIP免费指标”生效)
'vip_bypass': False, # VIP用户是否免费(功能计费层面的旁路,默认关闭)
# 各功能积分消耗(0表示免费)
'cost_ai_analysis': 10, # AI分析 每次消耗积分
@@ -127,10 +128,15 @@ class BillingService:
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute(
"SELECT vip_expires_at FROM qd_users WHERE id = ?",
(user_id,)
)
# Ensure lifetime membership monthly credits are granted (best-effort, silent on failure).
self._ensure_membership_schema_best_effort(cur)
self._grant_lifetime_monthly_credits_best_effort(cur, user_id)
try:
db.commit()
except Exception:
pass
cur.execute("SELECT vip_expires_at FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone()
cur.close()
@@ -152,6 +158,298 @@ class BillingService:
except Exception as e:
logger.error(f"get_user_vip_status failed: {e}")
return False, None
# ==================== Membership Plans (VIP) ====================
def get_membership_plans(self) -> Dict[str, Any]:
"""
Get membership plans from .env (configured via Settings UI).
Plan keys:
- monthly: price_usd, credits_once, duration_days
- yearly: price_usd, credits_once, duration_days
- lifetime: price_usd, credits_monthly (granted every 30 days)
"""
def _f(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)).strip())
except Exception:
return float(default)
def _i(key: str, default: int) -> int:
try:
return int(float(os.getenv(key, str(default)).strip()))
except Exception:
return int(default)
return {
"monthly": {
"plan": "monthly",
"price_usd": _f("MEMBERSHIP_MONTHLY_PRICE_USD", 19.9),
"credits_once": _i("MEMBERSHIP_MONTHLY_CREDITS", 500),
"duration_days": 30,
},
"yearly": {
"plan": "yearly",
"price_usd": _f("MEMBERSHIP_YEARLY_PRICE_USD", 199.0),
"credits_once": _i("MEMBERSHIP_YEARLY_CREDITS", 8000),
"duration_days": 365,
},
"lifetime": {
"plan": "lifetime",
"price_usd": _f("MEMBERSHIP_LIFETIME_PRICE_USD", 499.0),
# Lifetime: monthly credits granted periodically
"credits_monthly": _i("MEMBERSHIP_LIFETIME_MONTHLY_CREDITS", 800),
},
}
def purchase_membership(self, user_id: int, plan: str) -> Tuple[bool, str, Dict[str, Any]]:
"""
Purchase membership plan (mock payment: immediately activates).
NOTE: Real payment gateway can be integrated later; this function is the single activation point.
"""
plan = (plan or "").strip().lower()
plans = self.get_membership_plans()
if plan not in plans:
return False, "invalid_plan", {}
try:
with get_db_connection() as db:
cur = db.cursor()
self._ensure_membership_schema_best_effort(cur)
self._ensure_membership_orders_table_best_effort(cur)
now = datetime.now(timezone.utc)
# Read current VIP expiry to support stacking for monthly/yearly.
cur.execute("SELECT vip_expires_at FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone() or {}
current_expires = row.get("vip_expires_at")
if isinstance(current_expires, str) and current_expires:
try:
current_expires = datetime.fromisoformat(current_expires.replace("Z", "+00:00"))
except Exception:
current_expires = None
if current_expires and current_expires.tzinfo is None:
current_expires = current_expires.replace(tzinfo=timezone.utc)
base_time = current_expires if (current_expires and current_expires > now) else now
vip_expires_at = None
vip_plan = plan
vip_is_lifetime = False
if plan in ("monthly", "yearly"):
days = int(plans[plan].get("duration_days") or (30 if plan == "monthly" else 365))
vip_expires_at = base_time + timedelta(days=days)
else:
# Lifetime: set very long expiry + mark lifetime flag
vip_expires_at = now + timedelta(days=365 * 100)
vip_is_lifetime = True
# Create order record (mock paid)
order_plan = plan
order_price_usd = float(plans[plan].get("price_usd") or 0)
order_id = None
try:
cur.execute(
"""
INSERT INTO qd_membership_orders
(user_id, plan, price_usd, status, created_at, paid_at)
VALUES (?, ?, ?, 'paid', NOW(), NOW())
RETURNING id
""",
(user_id, order_plan, order_price_usd),
)
row2 = cur.fetchone() or {}
order_id = row2.get("id")
except Exception:
# Fallback for DB drivers without RETURNING support
cur.execute(
"""
INSERT INTO qd_membership_orders
(user_id, plan, price_usd, status, created_at, paid_at)
VALUES (?, ?, ?, 'paid', NOW(), NOW())
""",
(user_id, order_plan, order_price_usd),
)
order_id = getattr(cur, "lastrowid", None)
order_ref = str(order_id or "")
# Update user VIP fields
cur.execute(
"""
UPDATE qd_users
SET vip_expires_at = ?,
vip_plan = ?,
vip_is_lifetime = ?,
updated_at = NOW()
WHERE id = ?
""",
(vip_expires_at, vip_plan, 1 if vip_is_lifetime else 0, user_id),
)
# Credits grants
if plan in ("monthly", "yearly"):
credits_once = int(plans[plan].get("credits_once") or 0)
if credits_once > 0:
# Use add_credits to update balance and log
# NOTE: add_credits opens its own connection, so we do a direct update here for atomicity.
self._add_credits_in_tx(cur, user_id, credits_once, action="membership_bonus",
remark=f"Membership bonus ({plan})", reference_id=order_ref)
else:
# Lifetime: grant first month's credits immediately and set last grant time
monthly_credits = int(plans["lifetime"].get("credits_monthly") or 0)
if monthly_credits > 0:
self._add_credits_in_tx(cur, user_id, monthly_credits, action="membership_monthly",
remark="Lifetime membership monthly credits", reference_id=order_ref)
try:
cur.execute(
"UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?",
(now, user_id),
)
except Exception:
# Column may not exist; ignore
pass
# VIP log entry (for audit)
cur.execute(
"""
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at)
VALUES (?, 'membership_purchase', 0,
(SELECT credits FROM qd_users WHERE id = ?),
?, NULL, ?, NOW())
""",
(user_id, user_id, f"Membership purchased: {plan}", order_ref),
)
db.commit()
cur.close()
return True, "success", {
"order_id": order_id,
"plan": plan,
"vip_expires_at": vip_expires_at.isoformat() if vip_expires_at else None,
}
except Exception as e:
logger.error(f"purchase_membership failed: {e}", exc_info=True)
return False, f"error:{str(e)}", {}
def _ensure_membership_schema_best_effort(self, cur):
"""Best-effort schema upgrade for membership fields on qd_users."""
try:
# vip_plan / vip_is_lifetime / vip_monthly_credits_last_grant
cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_plan VARCHAR(20) DEFAULT ''")
cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_is_lifetime BOOLEAN DEFAULT FALSE")
cur.execute("ALTER TABLE qd_users ADD COLUMN IF NOT EXISTS vip_monthly_credits_last_grant TIMESTAMP")
except Exception:
# Ignore schema upgrade failures (e.g., insufficient privileges)
pass
def _ensure_membership_orders_table_best_effort(self, cur):
"""Best-effort create membership orders table (mock payment)."""
try:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS qd_membership_orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
plan VARCHAR(20) NOT NULL,
price_usd DECIMAL(10,2) DEFAULT 0,
status VARCHAR(20) DEFAULT 'paid',
created_at TIMESTAMP DEFAULT NOW(),
paid_at TIMESTAMP
)
"""
)
except Exception:
pass
def _add_credits_in_tx(self, cur, user_id: int, amount: int, action: str, remark: str, reference_id: str = ''):
"""Add credits within an existing DB transaction and write qd_credits_log."""
try:
cur.execute("SELECT credits FROM qd_users WHERE id = ?", (user_id,))
row = cur.fetchone() or {}
credits = Decimal(str(row.get("credits", 0) or 0))
new_balance = credits + Decimal(str(amount))
cur.execute("UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?", (float(new_balance), user_id))
cur.execute(
"""
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, remark, operator_id, reference_id, created_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, NOW())
""",
(user_id, action, amount, float(new_balance), remark, reference_id),
)
except Exception as e:
logger.debug(f"_add_credits_in_tx failed: {e}", exc_info=True)
def _grant_lifetime_monthly_credits_best_effort(self, cur, user_id: int):
"""Grant lifetime monthly credits if due (best-effort)."""
try:
plans = self.get_membership_plans()
monthly_credits = int(plans.get("lifetime", {}).get("credits_monthly") or 0)
if monthly_credits <= 0:
return
cur.execute(
"SELECT vip_is_lifetime, vip_expires_at, vip_monthly_credits_last_grant FROM qd_users WHERE id = ?",
(user_id,),
)
row = cur.fetchone() or {}
if not row.get("vip_is_lifetime"):
return
expires_at = row.get("vip_expires_at")
if isinstance(expires_at, str) and expires_at:
try:
expires_at = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
except Exception:
expires_at = None
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
if expires_at and expires_at <= now:
return
last = row.get("vip_monthly_credits_last_grant")
if isinstance(last, str) and last:
try:
last = datetime.fromisoformat(last.replace("Z", "+00:00"))
except Exception:
last = None
if last and last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
# First time: do nothing (purchase flow already grants), but set last to now if missing
if not last:
cur.execute(
"UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?",
(now, user_id),
)
return
# Use 30-day periods. Catch up up to 6 periods max to avoid abuse.
delta_days = int((now - last).total_seconds() // 86400)
periods = delta_days // 30
if periods <= 0:
return
if periods > 6:
periods = 6
total = monthly_credits * periods
self._add_credits_in_tx(cur, user_id, total, action="membership_monthly",
remark=f"Lifetime membership monthly credits x{periods}", reference_id="")
cur.execute(
"UPDATE qd_users SET vip_monthly_credits_last_grant = ?, updated_at = NOW() WHERE id = ?",
(now, user_id),
)
except Exception:
# Best-effort; never break caller
pass
def check_and_consume(self, user_id: int, feature: str, reference_id: str = '') -> Tuple[bool, str]:
"""
@@ -420,7 +718,7 @@ 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', True),
'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',
# 功能费用(供前端显示)
@@ -3,6 +3,7 @@ Community Service - 指标社区服务
处理指标市场、购买、评论等功能。
"""
import json
import time
from decimal import Decimal
from typing import Dict, Any, List, Optional, Tuple
@@ -19,6 +20,15 @@ class CommunityService:
def __init__(self):
self.billing = get_billing_service()
# Best-effort: ensure vip_free column exists (for old databases)
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free BOOLEAN DEFAULT FALSE")
db.commit()
cur.close()
except Exception:
pass
# ==========================================
# 指标市场
@@ -78,7 +88,7 @@ class CommunityService:
# 获取列表(联表查询作者信息)
query_sql = f"""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price,
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
i.view_count, i.created_at, i.updated_at,
u.id as author_id, u.username as author_username,
@@ -115,6 +125,7 @@ class CommunityService:
'description': row['description'][:200] if row['description'] else '',
'pricing_type': row['pricing_type'] or 'free',
'price': float(row['price'] or 0),
'vip_free': bool(row.get('vip_free') or False),
'preview_image': row['preview_image'] or '',
'purchase_count': row['purchase_count'] or 0,
'avg_rating': float(row['avg_rating'] or 0),
@@ -152,7 +163,7 @@ class CommunityService:
# 获取指标信息
cur.execute("""
SELECT
i.id, i.name, i.description, i.pricing_type, i.price,
i.id, i.name, i.description, i.pricing_type, i.price, COALESCE(i.vip_free, FALSE) as vip_free,
i.preview_image, i.purchase_count, i.avg_rating, i.rating_count,
i.view_count, i.publish_to_community, i.created_at, i.updated_at,
i.user_id,
@@ -196,6 +207,7 @@ class CommunityService:
'description': row['description'] or '',
'pricing_type': row['pricing_type'] or 'free',
'price': float(row['price'] or 0),
'vip_free': bool(row.get('vip_free') or False),
'preview_image': row['preview_image'] or '',
'purchase_count': row['purchase_count'] or 0,
'avg_rating': float(row['avg_rating'] or 0),
@@ -234,7 +246,7 @@ class CommunityService:
# 1. 获取指标信息
cur.execute("""
SELECT id, user_id, name, code, description, pricing_type, price,
SELECT id, user_id, name, code, description, pricing_type, price, COALESCE(vip_free, FALSE) as vip_free,
preview_image, is_encrypted
FROM qd_indicator_codes
WHERE id = ? AND publish_to_community = 1
@@ -248,6 +260,11 @@ class CommunityService:
seller_id = indicator['user_id']
price = float(indicator['price'] or 0)
pricing_type = indicator['pricing_type'] or 'free'
vip_free = bool(indicator.get('vip_free') or False)
is_vip, _ = self.billing.get_user_vip_status(buyer_id)
# VIP-free indicator: VIP users can get it without credits charge
effective_price = 0.0 if (vip_free and is_vip) else price
# 2. 检查是否购买自己的指标
if seller_id == buyer_id:
@@ -264,17 +281,17 @@ class CommunityService:
return False, 'already_purchased', {}
# 4. 如果是付费指标,检查并扣除积分
if pricing_type != 'free' and price > 0:
if pricing_type != 'free' and effective_price > 0:
buyer_credits = self.billing.get_user_credits(buyer_id)
if buyer_credits < price:
if buyer_credits < effective_price:
cur.close()
return False, 'insufficient_credits', {
'required': price,
'required': effective_price,
'current': float(buyer_credits)
}
# 扣除买家积分
new_buyer_balance = buyer_credits - Decimal(str(price))
new_buyer_balance = buyer_credits - Decimal(str(effective_price))
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(float(new_buyer_balance), buyer_id)
@@ -285,12 +302,12 @@ class CommunityService:
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
VALUES (?, 'indicator_purchase', ?, ?, 'indicator_purchase', ?, ?, NOW())
""", (buyer_id, -price, float(new_buyer_balance), str(indicator_id),
""", (buyer_id, -effective_price, float(new_buyer_balance), str(indicator_id),
f"购买指标: {indicator['name']}"))
# 给卖家增加积分(可配置抽成比例,这里先100%给卖家)
seller_credits = self.billing.get_user_credits(seller_id)
new_seller_balance = seller_credits + Decimal(str(price))
new_seller_balance = seller_credits + Decimal(str(effective_price))
cur.execute(
"UPDATE qd_users SET credits = ?, updated_at = NOW() WHERE id = ?",
(float(new_seller_balance), seller_id)
@@ -301,7 +318,7 @@ class CommunityService:
INSERT INTO qd_credits_log
(user_id, action, amount, balance_after, feature, reference_id, remark, created_at)
VALUES (?, 'indicator_sale', ?, ?, 'indicator_sale', ?, ?, NOW())
""", (seller_id, price, float(new_seller_balance), str(indicator_id),
""", (seller_id, effective_price, float(new_seller_balance), str(indicator_id),
f"出售指标: {indicator['name']}"))
# 5. 创建购买记录
@@ -309,16 +326,16 @@ class CommunityService:
INSERT INTO qd_indicator_purchases
(indicator_id, buyer_id, seller_id, price, created_at)
VALUES (?, ?, ?, ?, NOW())
""", (indicator_id, buyer_id, seller_id, price))
""", (indicator_id, buyer_id, seller_id, effective_price))
# 6. 复制指标到买家账户
now_ts = int(time.time())
cur.execute("""
INSERT INTO qd_indicator_codes
(user_id, is_buy, end_time, name, code, description,
publish_to_community, pricing_type, price, is_encrypted, preview_image,
publish_to_community, pricing_type, price, is_encrypted, preview_image, vip_free,
createtime, updatetime, created_at, updated_at)
VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, ?, ?, NOW(), NOW())
VALUES (?, 1, 0, ?, ?, ?, 0, 'free', 0, ?, ?, 0, ?, ?, NOW(), NOW())
""", (
buyer_id,
indicator['name'],
@@ -339,8 +356,8 @@ class CommunityService:
db.commit()
cur.close()
logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {price} credits")
return True, 'success', {'indicator_name': indicator['name'], 'price': price}
logger.info(f"User {buyer_id} purchased indicator {indicator_id} for {effective_price} credits (vip_free={vip_free}, is_vip={is_vip})")
return True, 'success', {'indicator_name': indicator['name'], 'price': price, 'charged': effective_price, 'vip_free': vip_free}
except Exception as e:
logger.error(f"purchase_indicator failed: {e}")
@@ -864,14 +881,16 @@ class CommunityService:
return {'pending': 0, 'approved': 0, 'rejected': 0}
# ==========================================
# 实盘表现(聚合回测数据)
# 实盘表现(聚合回测 + 实盘交易数据)
# ==========================================
def get_indicator_performance(self, indicator_id: int) -> Dict[str, Any]:
"""
获取指标的实盘表现统计
目前基于回测数据统计,未来可扩展为实盘交易数据
数据来源:
1. qd_backtest_runs - 回测记录(result_json 内含 totalReturn / winRate 等)
2. qd_strategy_trades + qd_strategies_trading - 真实实盘交易记录
"""
default_result = {
'strategy_count': 0,
@@ -881,49 +900,125 @@ class CommunityService:
'avg_return': 0,
'max_drawdown': 0
}
try:
with get_db_connection() as db:
cur = db.cursor()
# 首先检查回测记录表是否存在
cur.execute("""
SELECT COUNT(*) as cnt FROM information_schema.tables
WHERE table_name = 'qd_backtest_runs'
""")
table_exists = cur.fetchone()
if not table_exists or table_exists['cnt'] == 0:
cur.close()
return default_result
# 从回测记录中统计该指标的表现
# 使用 indicator_id 字段匹配
cur.execute("""
SELECT
COUNT(*) as run_count,
AVG(CASE WHEN total_return IS NOT NULL THEN total_return ELSE 0 END) as avg_return,
AVG(CASE WHEN win_rate IS NOT NULL THEN win_rate ELSE 0 END) as avg_win_rate,
AVG(CASE WHEN max_drawdown IS NOT NULL THEN max_drawdown ELSE 0 END) as avg_drawdown,
SUM(CASE WHEN trade_count IS NOT NULL THEN trade_count ELSE 0 END) as total_trades
FROM qd_backtest_runs
WHERE indicator_id = ?
""", (indicator_id,))
row = cur.fetchone()
# ---------- Part 1: 回测数据(从 result_json 解析) ----------
bt_returns = []
bt_win_rates = []
bt_drawdowns = []
bt_trade_counts = []
try:
cur.execute("""
SELECT result_json
FROM qd_backtest_runs
WHERE indicator_id = %s AND status = 'success'
AND result_json IS NOT NULL AND result_json != ''
""", (indicator_id,))
rows = cur.fetchall()
for row in rows:
try:
rj = json.loads(row['result_json']) if isinstance(row['result_json'], str) else {}
tr = float(rj.get('totalReturn', 0) or 0)
wr = float(rj.get('winRate', 0) or 0)
md = float(rj.get('maxDrawdown', 0) or 0)
tc = int(rj.get('totalTrades', 0) or 0)
bt_returns.append(tr)
bt_win_rates.append(wr)
bt_drawdowns.append(md)
bt_trade_counts.append(tc)
except (json.JSONDecodeError, TypeError, ValueError):
continue
except Exception:
logger.debug("Backtest runs query skipped or failed", exc_info=True)
bt_run_count = len(bt_returns)
# ---------- Part 2: 实盘交易数据 ----------
live_strategy_count = 0
live_trade_count = 0
live_win_rate = 0.0
live_total_profit = 0.0
try:
# 找出使用该指标的策略(indicator_config JSON 中 indicator_id 匹配)
cur.execute("""
SELECT id FROM qd_strategies_trading
WHERE indicator_config::text LIKE %s
""", (f'%"indicator_id": {indicator_id}%',))
strategy_rows = cur.fetchall()
# 也尝试匹配无空格的格式
if not strategy_rows:
cur.execute("""
SELECT id FROM qd_strategies_trading
WHERE indicator_config::text LIKE %s
""", (f'%"indicator_id":{indicator_id}%',))
strategy_rows = cur.fetchall()
if strategy_rows:
strategy_ids = [r['id'] for r in strategy_rows]
live_strategy_count = len(strategy_ids)
placeholders = ','.join(['%s'] * len(strategy_ids))
cur.execute(f"""
SELECT
COUNT(*) as trade_count,
SUM(CASE WHEN profit > 0 THEN 1 ELSE 0 END) as win_count,
SUM(profit) as total_profit
FROM qd_strategy_trades
WHERE strategy_id IN ({placeholders})
AND profit != 0
""", tuple(strategy_ids))
trade_row = cur.fetchone()
if trade_row and (trade_row['trade_count'] or 0) > 0:
live_trade_count = int(trade_row['trade_count'] or 0)
win_count = int(trade_row['win_count'] or 0)
live_win_rate = round(win_count / live_trade_count * 100, 2) if live_trade_count > 0 else 0.0
live_total_profit = round(float(trade_row['total_profit'] or 0), 2)
except Exception:
logger.debug("Live trading query skipped or failed", exc_info=True)
cur.close()
if not row or row['run_count'] == 0:
# ---------- Combine results ----------
total_strategy_count = bt_run_count + live_strategy_count
total_trade_count = sum(bt_trade_counts) + live_trade_count
# 综合胜率:优先实盘 > 回测平均
if live_trade_count > 0:
combined_win_rate = live_win_rate
elif bt_win_rates:
combined_win_rate = round(sum(bt_win_rates) / len(bt_win_rates), 2)
else:
combined_win_rate = 0.0
# 平均收益率(回测 totalReturn %
avg_return = round(sum(bt_returns) / len(bt_returns), 2) if bt_returns else 0.0
# 总利润:优先用实盘绝对利润,无实盘则显示回测平均收益率
combined_profit = live_total_profit if live_trade_count > 0 else avg_return
# 最大回撤取回测中最差的(maxDrawdown 是负数,取最小即最差)
avg_drawdown = round(min(bt_drawdowns), 2) if bt_drawdowns else 0.0
if total_strategy_count == 0 and total_trade_count == 0:
return default_result
return {
'strategy_count': row['run_count'] or 0,
'trade_count': row['total_trades'] or 0,
'win_rate': round(float(row['avg_win_rate'] or 0), 2),
'total_profit': round(float(row['avg_return'] or 0), 2),
'avg_return': round(float(row['avg_return'] or 0), 2),
'max_drawdown': round(float(row['avg_drawdown'] or 0), 2)
'strategy_count': total_strategy_count,
'trade_count': total_trade_count,
'win_rate': combined_win_rate,
'total_profit': round(combined_profit, 2),
'avg_return': avg_return,
'max_drawdown': avg_drawdown
}
except Exception as e:
logger.error(f"get_indicator_performance failed: {e}")
return default_result
@@ -1,6 +1,6 @@
# Interactive Brokers Trading Module
Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway.
Supports US stocks trading via TWS or IB Gateway.
## Installation
@@ -76,10 +76,10 @@ curl -X POST http://localhost:5000/api/ibkr/order \
-H "Content-Type: application/json" \
-d '{"symbol": "AAPL", "side": "buy", "quantity": 10, "marketType": "USStock"}'
# Limit order: sell 100 shares of Tencent
# Limit order: sell 100 shares of MSFT
curl -X POST http://localhost:5000/api/ibkr/order \
-H "Content-Type: application/json" \
-d '{"symbol": "0700.HK", "side": "sell", "quantity": 100, "marketType": "HShare", "orderType": "limit", "price": 300}'
-d '{"symbol": "MSFT", "side": "sell", "quantity": 100, "marketType": "USStock", "orderType": "limit", "price": 400}'
```
### Get Positions
@@ -93,7 +93,6 @@ curl http://localhost:5000/api/ibkr/positions
| Market | Format | Examples |
|--------|--------|----------|
| US Stock | Ticker symbol | `AAPL`, `TSLA`, `GOOGL` |
| HK Stock | `XXXX.HK` or digits | `0700.HK`, `00700`, `700` |
## Important Notes
@@ -1,7 +1,7 @@
"""
Interactive Brokers (IBKR) Trading Module
Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway.
Supports US stocks trading via TWS or IB Gateway.
Port Reference:
- TWS Live: 7497, TWS Paper: 7496
@@ -180,7 +180,7 @@ class IBKRClient:
Args:
symbol: Symbol code
market_type: Market type (USStock, HShare)
market_type: Market type (USStock)
"""
_ensure_ib_insync()
@@ -219,7 +219,7 @@ class IBKRClient:
symbol: Symbol code (e.g., AAPL, 0700.HK)
side: Direction ("buy" or "sell")
quantity: Number of shares
market_type: Market type ("USStock" or "HShare")
market_type: Market type ("USStock")
Returns:
OrderResult
@@ -13,7 +13,7 @@ def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]:
Args:
symbol: Symbol code in the system
market_type: Market type (USStock, HShare)
market_type: Market type (USStock)
Returns:
(ib_symbol, exchange, currency)
@@ -26,22 +26,6 @@ def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]:
# Use SMART routing for best execution
return symbol, "SMART", "USD"
elif market_type == "HShare":
# Hong Kong stock formats:
# - 0700.HK -> 700
# - 00700 -> 700
# - 700 -> 700
ib_symbol = symbol
# Remove .HK suffix
if ib_symbol.endswith(".HK"):
ib_symbol = ib_symbol[:-3]
# Remove leading zeros
ib_symbol = ib_symbol.lstrip("0") or "0"
return ib_symbol, "SEHK", "HKD"
else:
# Default to US stock
return symbol, "SMART", "USD"
@@ -59,15 +43,6 @@ def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]:
"""
symbol = (symbol or "").strip().upper()
# HK stock: ends with .HK or all digits
if symbol.endswith(".HK"):
return symbol, "HShare"
# All digits (likely HK stock code)
clean = symbol.lstrip("0")
if clean.isdigit() and len(clean) <= 5:
return symbol, "HShare"
# Default to US stock
return symbol, "USStock"
@@ -83,8 +58,4 @@ def format_display_symbol(ib_symbol: str, exchange: str) -> str:
Returns:
Display symbol
"""
if exchange == "SEHK":
# HK stock: pad to 4 digits, add .HK
padded = ib_symbol.zfill(4)
return f"{padded}.HK"
return ib_symbol
+2 -2
View File
@@ -30,7 +30,7 @@ class KlineService:
获取K线数据
Args:
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
market: 市场类型 (Crypto, USStock, Forex, Futures)
symbol: 交易对/股票代码
timeframe: 时间周期
limit: 数据条数
@@ -76,7 +76,7 @@ class KlineService:
获取实时价格优先使用 ticker API降级使用分钟 K 线
Args:
market: 市场类型 (Crypto, USStock, AShare, HShare, Forex, Futures)
market: 市场类型 (Crypto, USStock, Forex, Futures)
symbol: 交易对/股票代码
force_refresh: 是否强制刷新跳过缓存
@@ -3,7 +3,7 @@ Translate a strategy signal into a direct-exchange order call.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -203,7 +203,7 @@ def _place_ibkr_order(
exchange_config: Optional[Dict[str, Any]] = None,
) -> LiveOrderResult:
"""
Place order via IBKR for US/HK stocks.
Place order via IBKR for US stocks.
Signal mapping for stocks (no short selling in this implementation):
- open_long / add_long -> BUY
@@ -3,7 +3,7 @@ Factory for direct exchange clients.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -131,7 +131,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
market_type=mt,
)
# Traditional brokers (IBKR for US/HK stocks only)
# Traditional brokers (IBKR for US stocks only)
if exchange_id == "ibkr":
# Note: Market category validation should be done at the caller level
# This factory only creates clients based on exchange_id
@@ -148,7 +148,7 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
def create_ibkr_client(exchange_config: Dict[str, Any]):
"""
Create IBKR client for US/HK stock trading.
Create IBKR client for US stock trading.
exchange_config should contain:
- ibkr_host: TWS/Gateway host (default: 127.0.0.1)
@@ -11,7 +11,7 @@
- 价格/K线: DataSourceFactory (已验证与K线模块自选列表一致)
- 宏观数据: 复用 global_market.py (VIX, DXY, TNX, Fear&Greed等带缓存)
- 新闻: Finnhub API (结构化数据无需深度阅读)
- 基本面: Finnhub (美股) / akshare (A股) / 固定描述 (加密)
- 基本面: Finnhub (美股) / 固定描述 (加密)
"""
import time
@@ -59,12 +59,12 @@ class MarketDataCollector:
except Exception as e:
logger.warning(f"Finnhub client init failed: {e}")
# akshare
# akshare (optional, for supplementary data)
try:
import akshare as ak
self._ak = ak
except ImportError:
logger.info("akshare not installed, A-share data will be limited")
logger.info("akshare not installed")
def collect_all(
self,
@@ -79,7 +79,7 @@ class MarketDataCollector:
采集所有市场数据
Args:
market: 市场类型 (USStock, Crypto, AShare, HShare, Forex, Futures)
market: 市场类型 (USStock, Crypto, Forex, Futures)
symbol: 标的代码
timeframe: K线周期
include_macro: 是否包含宏观数据
@@ -124,7 +124,7 @@ class MarketDataCollector:
}
# 如果需要基本面,也并行获取
if market in ('USStock', 'AShare', 'HShare'):
if market == 'USStock':
core_futures[executor.submit(self._get_fundamental, market, symbol)] = "fundamental"
core_futures[executor.submit(self._get_company, market, symbol)] = "company"
elif market == 'Crypto':
@@ -547,10 +547,6 @@ class MarketDataCollector:
try:
if market == 'USStock':
return self._get_us_fundamental(symbol)
elif market == 'AShare':
return self._get_ashare_fundamental(symbol)
elif market == 'HShare':
return self._get_hshare_fundamental(symbol)
except Exception as e:
logger.warning(f"Fundamental data fetch failed for {market}:{symbol}: {e}")
return None
@@ -602,56 +598,6 @@ class MarketDataCollector:
return result if result else None
def _get_ashare_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""A股基本面 - akshare"""
if not self._ak:
return None
try:
# 个股指标
df = self._ak.stock_individual_info_em(symbol=symbol)
if df is not None and not df.empty:
result = {}
for _, row in df.iterrows():
item = row.get('item', '')
value = row.get('value', '')
if '市盈率' in item:
result['pe_ratio'] = value
elif '市净率' in item:
result['pb_ratio'] = value
elif '总市值' in item:
result['market_cap'] = value
elif 'ROE' in item or '净资产收益率' in item:
result['roe'] = value
elif '每股收益' in item:
result['eps'] = value
return result if result else None
except Exception as e:
logger.debug(f"akshare fundamental failed for {symbol}: {e}")
return None
def _get_hshare_fundamental(self, symbol: str) -> Optional[Dict[str, Any]]:
"""港股基本面 - yfinance"""
try:
# 港股在yfinance的格式: 0700.HK, 9988.HK
yf_symbol = f"{symbol}.HK"
ticker = yf.Ticker(yf_symbol)
info = ticker.info or {}
return {
'pe_ratio': info.get('trailingPE'),
'pb_ratio': info.get('priceToBook'),
'market_cap': info.get('marketCap'),
'dividend_yield': info.get('dividendYield'),
'52w_high': info.get('fiftyTwoWeekHigh'),
'52w_low': info.get('fiftyTwoWeekLow'),
}
except Exception as e:
logger.debug(f"yfinance HShare fundamental failed for {symbol}: {e}")
return None
def _get_crypto_info(self, symbol: str) -> Optional[Dict[str, Any]]:
"""加密货币信息 (固定描述为主)"""
# 常见加密货币的描述
@@ -717,19 +663,6 @@ class MarketDataCollector:
'website': profile.get('weburl'),
}
elif market == 'AShare' and self._ak:
df = self._ak.stock_individual_info_em(symbol=symbol)
if df is not None and not df.empty:
result = {}
for _, row in df.iterrows():
item = row.get('item', '')
value = row.get('value', '')
if '名称' in item or '简称' in item:
result['name'] = value
elif '行业' in item:
result['industry'] = value
return result if result else None
except Exception as e:
logger.debug(f"Company info fetch failed for {market}:{symbol}: {e}")
@@ -892,9 +825,8 @@ class MarketDataCollector:
策略按优先级
1. 结构化API (Finnhub) - 美股首选
2. akshare 多源 - A股首选东方财富/新浪/同花顺/雪球
3. 搜索引擎 (Bocha/Tavily) - 补充搜索
4. 情绪分析 - Finnhub 社交媒体情绪
2. 搜索引擎 (Bocha/Tavily) - 补充搜索
3. 情绪分析 - Finnhub 社交媒体情绪
"""
news_list = []
sentiment = {}
@@ -912,7 +844,7 @@ class MarketDataCollector:
elif market == 'Crypto':
# 加密货币通用新闻
raw_news = self._finnhub_client.general_news('crypto', min_id=0)
elif market not in ('AShare', 'HShare'):
else:
# 其他市场通用新闻
raw_news = self._finnhub_client.general_news('general', min_id=0)
@@ -942,17 +874,7 @@ class MarketDataCollector:
except Exception as e:
logger.debug(f"Finnhub sentiment fetch failed: {e}")
# === 3) A股多源新闻 (akshare) ===
if market == 'AShare' and self._ak:
ashare_news = self._get_ashare_news_multi_source(symbol)
news_list.extend(ashare_news)
# === 4) 港股新闻 (akshare) ===
if market == 'HShare' and self._ak:
hshare_news = self._get_hshare_news(symbol)
news_list.extend(hshare_news)
# === 5) 搜索引擎补充 (如果新闻太少) ===
# === 3) 搜索引擎补充 (如果新闻太少) ===
if len(news_list) < 5:
search_news = self._get_news_from_search(market, symbol, company_name)
news_list.extend(search_news)
@@ -974,108 +896,6 @@ class MarketDataCollector:
"sentiment": sentiment,
}
def _get_ashare_news_multi_source(self, symbol: str) -> List[Dict[str, Any]]:
"""
A股多源新闻获取
来源按优先级
1. 东方财富个股新闻 (stock_news_em)
2. 新浪财经滚动新闻 (stock_news_sina)
3. 同花顺个股新闻 (stock_news_ths)
4. 雪球热帖 (stock_xuqiu)
"""
news_list = []
# 1) 东方财富个股新闻
try:
df = self._ak.stock_news_em(symbol=symbol)
if df is not None and not df.empty:
for _, row in df.head(8).iterrows():
news_list.append({
"datetime": str(row.get('发布时间', ''))[:16],
"headline": row.get('新闻标题', ''),
"summary": row.get('新闻内容', '')[:200] if row.get('新闻内容') else '',
"source": "东方财富",
"url": row.get('新闻链接', ''),
"sentiment": 'neutral',
})
logger.debug(f"东方财富新闻: {len(df)}")
except Exception as e:
logger.debug(f"东方财富新闻获取失败: {e}")
# 2) 新浪财经个股新闻
try:
# 注意:akshare 的新浪新闻接口可能需要股票名称而非代码
df = self._ak.stock_news_sina(symbol=symbol)
if df is not None and not df.empty:
for _, row in df.head(5).iterrows():
title = row.get('title', '') or row.get('新闻标题', '')
if title and title not in [n.get('headline') for n in news_list]:
news_list.append({
"datetime": str(row.get('time', row.get('发布时间', '')))[:16],
"headline": title,
"summary": row.get('content', row.get('新闻内容', ''))[:200] if row.get('content') or row.get('新闻内容') else '',
"source": "新浪财经",
"url": row.get('url', row.get('新闻链接', '')),
"sentiment": 'neutral',
})
logger.debug(f"新浪财经新闻: {len(df)}")
except Exception as e:
logger.debug(f"新浪财经新闻获取失败: {e}")
# 3) 同花顺个股新闻
try:
df = self._ak.stock_news_ths(symbol=symbol)
if df is not None and not df.empty:
for _, row in df.head(5).iterrows():
title = row.get('标题', '') or row.get('title', '')
if title and title not in [n.get('headline') for n in news_list]:
news_list.append({
"datetime": str(row.get('发布时间', row.get('time', '')))[:16],
"headline": title,
"summary": row.get('内容', row.get('content', ''))[:200] if row.get('内容') or row.get('content') else '',
"source": "同花顺",
"url": row.get('链接', row.get('url', '')),
"sentiment": 'neutral',
})
logger.debug(f"同花顺新闻: {len(df)}")
except Exception as e:
logger.debug(f"同花顺新闻获取失败: {e}")
# 4) 雪球热帖(社区讨论)
try:
df = self._ak.stock_xuqiu(symbol=symbol)
if df is not None and not df.empty:
for _, row in df.head(3).iterrows():
title = row.get('标题', '') or row.get('title', '')
if title and title not in [n.get('headline') for n in news_list]:
news_list.append({
"datetime": str(row.get('发布时间', row.get('time', '')))[:16],
"headline": title,
"summary": row.get('内容摘要', row.get('content', ''))[:200] if row.get('内容摘要') or row.get('content') else '',
"source": "雪球",
"url": row.get('链接', row.get('url', '')),
"sentiment": 'neutral',
})
logger.debug(f"雪球热帖: {len(df)}")
except Exception as e:
logger.debug(f"雪球热帖获取失败: {e}")
return news_list
def _get_hshare_news(self, symbol: str) -> List[Dict[str, Any]]:
"""港股新闻获取"""
news_list = []
try:
# 港股新闻 (如果 akshare 支持)
df = self._ak.stock_hk_spot_em()
# 港股一般没有专门的新闻接口,可以通过搜索补充
except Exception as e:
logger.debug(f"港股新闻获取失败: {e}")
return news_list
def _get_news_from_search(
self, market: str, symbol: str, company_name: str = None
) -> List[Dict[str, Any]]:
@@ -838,19 +838,19 @@ class PendingOrderWorker:
market_category = str(cfg.get("market_category") or "Crypto").strip()
# Validate market category and exchange_id combination for live trading
# AShare and Futures do not support live trading
if market_category in ("AShare", "Futures"):
# Futures does not support live trading
if market_category in ("Futures",):
self._mark_failed(order_id=order_id, error=f"live_trading_not_supported_for_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} {market_category} does not support live trading")
_notify_live_best_effort(status="failed", error=f"live_trading_not_supported_for_{market_category.lower()}")
return
# Validate IBKR only for USStock/HShare
# Validate IBKR only for USStock
if exchange_id == "ibkr":
if market_category not in ("USStock", "HShare"):
self._mark_failed(order_id=order_id, error=f"ibkr_only_supports_usstock_hshare_got_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock/HShare, got {market_category}")
_notify_live_best_effort(status="failed", error=f"ibkr_only_supports_usstock_hshare_got_{market_category.lower()}")
if market_category not in ("USStock",):
self._mark_failed(order_id=order_id, error=f"ibkr_only_supports_usstock_got_{market_category.lower()}")
_console_print(f"[worker] order rejected: strategy_id={strategy_id} pending_id={order_id} IBKR only supports USStock, got {market_category}")
_notify_live_best_effort(status="failed", error=f"ibkr_only_supports_usstock_got_{market_category.lower()}")
return
# Validate MT5 only for Forex
@@ -884,7 +884,7 @@ class PendingOrderWorker:
_notify_live_best_effort(status="failed", error=f"create_client_failed:{e}")
return
# Check if this is an IBKR client (US/HK stocks)
# Check if this is an IBKR client (US stocks)
global IBKRClient
if IBKRClient is None:
try:
@@ -961,7 +961,7 @@ class PendingOrderWorker:
# Unified maker->market fallback settings
# Priority: payload config > environment variable > default value
_default_order_mode = os.getenv("ORDER_MODE", "maker").strip().lower()
_default_order_mode = os.getenv("ORDER_MODE", "market").strip().lower()
_default_maker_wait_sec = float(os.getenv("MAKER_WAIT_SEC", "10"))
_default_maker_offset_bps = float(os.getenv("MAKER_OFFSET_BPS", "2"))
@@ -1925,7 +1925,7 @@ class PendingOrderWorker:
_console_print,
) -> None:
"""
Execute order via Interactive Brokers for US/HK stocks.
Execute order via Interactive Brokers for US stocks.
Simplified flow compared to crypto (no maker->market fallback):
- Place market order directly
@@ -1957,7 +1957,7 @@ class PendingOrderWorker:
_notify_live_best_effort(status="failed", error=f"ibkr_unsupported_signal:{signal_type}")
return
# Get market type (USStock or HShare)
# Get market type (USStock)
market_type = str(
payload.get("market_type") or
payload.get("market_category") or
+6 -6
View File
@@ -3,7 +3,7 @@ Search service v2.0 - 增强版搜索服务
整合多个搜索引擎支持 API Key 轮换和故障转移
支持的搜索引擎按优先级
1. Bocha (博查) - 国内搜索优化A股新闻推荐
1. Bocha (博查) - 搜索优化
2. Tavily - 专为AI设计免费1000次/
3. SerpAPI - Google/Bing 结果抓取
4. Google CSE - 自定义搜索引擎
@@ -972,7 +972,7 @@ class SearchService:
self,
stock_code: str,
stock_name: str,
market: str = "AShare",
market: str = "USStock",
max_results: int = 5
) -> SearchResponse:
"""
@@ -997,14 +997,14 @@ class SearchService:
search_days = 1
# 根据市场类型构建搜索查询
if market == "AShare":
query = f"{stock_name} {stock_code} 股票 最新消息 利好 利空"
elif market == "USStock":
if market == "USStock":
query = f"{stock_name} {stock_code} stock news latest"
elif market == "Crypto":
query = f"{stock_name} crypto news price analysis"
elif market == "Forex":
query = f"{stock_name} {stock_code} forex news analysis"
else:
query = f"{stock_name} {stock_code} 最新消息"
query = f"{stock_name} {stock_code} latest news"
logger.info(f"搜索股票新闻: {stock_name}({stock_code}), market={market}, days={search_days}")
@@ -6,8 +6,6 @@ Goal:
from public data sources, then persist it into watchlist records.
Notes:
- For A shares we prefer akshare when available (requested).
- For H shares we use Tencent quote API (no key required).
- For US stocks we use Finnhub (if configured) or yfinance.
- For Crypto/Forex/Futures we provide best-effort fallbacks.
"""
@@ -26,122 +24,13 @@ from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
logger = get_logger(__name__)
try:
import akshare as ak # type: ignore
HAS_AKSHARE = True
except Exception:
ak = None
HAS_AKSHARE = False
def _normalize_symbol_for_market(market: str, symbol: str) -> str:
m = (market or '').strip()
s = (symbol or '').strip().upper()
if not m or not s:
return s
if m == 'AShare' and s.isdigit():
return s.zfill(6)
if m == 'HShare' and s.isdigit():
return s.zfill(5)
return s
def _tencent_quote_code(market: str, symbol: str) -> Optional[str]:
"""
Convert symbol to Tencent quote code, e.g.
- AShare: sh600000 / sz000001 / bj430047
- HShare: hk00700
"""
m = (market or '').strip()
s = _normalize_symbol_for_market(m, symbol)
if not s:
return None
if m == 'AShare':
if s.startswith('6'):
return f"sh{s}"
if s.startswith('0') or s.startswith('3'):
return f"sz{s}"
if s.startswith('4') or s.startswith('8'):
return f"bj{s}"
return None
if m == 'HShare':
if s.isdigit():
return f"hk{s}"
# allow already prefixed
if s.startswith('HK') and s[2:].isdigit():
return f"hk{s[2:]}"
if s.startswith('HK') and len(s) > 2:
return f"hk{s[2:]}"
return f"hk{s}"
return None
def _resolve_name_from_tencent(market: str, symbol: str) -> Optional[str]:
"""
Tencent quote endpoint: http://qt.gtimg.cn/q=sz000858
Returns:
v_sz000858="51~五 粮 液~000858~..."; -> name is the 2nd field split by '~'
"""
code = _tencent_quote_code(market, symbol)
if not code:
return None
try:
url = f"http://qt.gtimg.cn/q={code}"
resp = requests.get(url, timeout=5)
# Tencent often responds in GBK for Chinese names
resp.encoding = 'gbk'
text = resp.text or ''
# Extract quoted payload
m = re.search(r'="([^"]*)"', text)
payload = m.group(1) if m else ''
if not payload:
return None
parts = payload.split('~')
if len(parts) < 2:
return None
name = (parts[1] or '').strip().replace(' ', '')
return name if name else None
except Exception as e:
logger.debug(f"Tencent name resolve failed: {market} {symbol}: {e}")
return None
def _resolve_name_from_akshare_ashare(symbol: str) -> Optional[str]:
"""
Resolve A-share name via akshare (no API key required).
"""
if not HAS_AKSHARE or ak is None:
return None
try:
# Prefer per-symbol endpoint (avoids fetching the whole market list).
if hasattr(ak, "stock_individual_info_em"):
df = ak.stock_individual_info_em(symbol=symbol)
if df is not None and not df.empty and 'item' in df.columns and 'value' in df.columns:
info = {str(r['item']).strip(): r['value'] for _, r in df.iterrows()}
name = str(info.get('股票简称') or info.get('证券简称') or '').strip()
return name if name else None
# Fallback: spot list (may be slow / large)
if hasattr(ak, "stock_zh_a_spot_em"):
df2 = ak.stock_zh_a_spot_em()
if df2 is not None and not df2.empty:
row = df2[df2['代码'] == symbol].iloc[0]
name = str(row.get('名称') or '').strip()
return name if name else None
except Exception as e:
logger.debug(f"akshare name resolve failed (AShare {symbol}): {e}")
return None
return None
def _resolve_name_from_yfinance(symbol: str) -> Optional[str]:
"""
Best-effort company name via yfinance.
@@ -211,13 +100,6 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
return seed
# 2) Market-specific
if m == 'AShare':
# Requested: use akshare for A shares, do not depend on Tencent by default.
return _resolve_name_from_akshare_ashare(s)
if m == 'HShare':
return _resolve_name_from_tencent(m, s)
if m == 'USStock':
# Prefer Finnhub if configured (more stable for company name),
# otherwise fall back to yfinance.
@@ -239,5 +121,3 @@ def resolve_symbol_name(market: str, symbol: str) -> Optional[str]:
return s
return None
@@ -556,7 +556,7 @@ class TradingExecutor:
trade_direction = 'long' # 现货只能做多
logger.info(f"Strategy {strategy_id} spot trading; force trade_direction=long")
# 获取市场类别(Crypto, USStock, Forex, Futures, AShare, HShare
# 获取市场类别(Crypto, USStock, Forex, Futures
# 这决定了使用哪个数据源来获取价格和K线数据
market_category = (strategy.get('market_category') or 'Crypto').strip()
logger.info(f"Strategy {strategy_id} market_category: {market_category}")
@@ -1131,7 +1131,7 @@ class TradingExecutor:
symbol: 交易对/代码
timeframe: 时间周期
limit: 数据条数
market_category: 市场类型 (Crypto, USStock, Forex, Futures, AShare, HShare)
market_category: 市场类型 (Crypto, USStock, Forex, Futures)
"""
try:
# 使用 KlineService 获取K线数据(自动处理缓存)
@@ -1153,7 +1153,7 @@ class TradingExecutor:
exchange: 交易所实例信号模式下为 None
symbol: 交易对/代码
market_type: 交易类型 (swap/spot)
market_category: 市场类型 (Crypto, USStock, Forex, Futures, AShare, HShare)
market_category: 市场类型 (Crypto, USStock, Forex, Futures)
"""
# Local in-memory cache first
cache_key = f"{market_category}:{(symbol or '').strip().upper()}"
@@ -1173,7 +1173,7 @@ class TradingExecutor:
try:
# 根据 market_category 选择正确的数据源
# 支持: Crypto, USStock, Forex, Futures, AShare, HShare
# 支持: Crypto, USStock, Forex, Futures
ticker = DataSourceFactory.get_ticker(market_category, symbol)
if ticker:
price = float(ticker.get('last') or ticker.get('close') or 0)
@@ -0,0 +1,366 @@
"""
USDT Payment Service (方案B每单独立地址 + 自动对账)
MVP:
- 只支持 USDT-TRC20
- 使用 XPUB 派生地址服务端只保存 xpub不保存私钥
- 通过 TronGrid API 轮询到账前端轮询订单状态时触发刷新
"""
import os
import time
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from typing import Any, Dict, Optional, Tuple
import requests
from app.utils.db import get_db_connection
from app.utils.logger import get_logger
from app.services.billing_service import get_billing_service
logger = get_logger(__name__)
class UsdtPaymentService:
def __init__(self):
self.billing = get_billing_service()
# -------------------- Config --------------------
def _get_cfg(self) -> Dict[str, Any]:
return {
"enabled": str(os.getenv("USDT_PAY_ENABLED", "False")).lower() in ("1", "true", "yes"),
"chain": (os.getenv("USDT_PAY_CHAIN", "TRC20") or "TRC20").upper(),
"xpub_trc20": (os.getenv("USDT_TRC20_XPUB", "") or "").strip(),
"trongrid_base": (os.getenv("TRONGRID_BASE_URL", "https://api.trongrid.io") or "").strip().rstrip("/"),
"trongrid_key": (os.getenv("TRONGRID_API_KEY", "") or "").strip(),
"usdt_trc20_contract": (os.getenv("USDT_TRC20_CONTRACT", "TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj") or "").strip(),
"confirm_seconds": int(float(os.getenv("USDT_PAY_CONFIRM_SECONDS", "30") or 30)),
"order_expire_minutes": int(float(os.getenv("USDT_PAY_EXPIRE_MINUTES", "30") or 30)),
}
# -------------------- Schema --------------------
def _ensure_schema_best_effort(self, cur):
"""Best-effort create table/columns for old databases."""
try:
cur.execute(
"""
CREATE TABLE IF NOT EXISTS qd_usdt_orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
plan VARCHAR(20) NOT NULL,
chain VARCHAR(20) NOT NULL DEFAULT 'TRC20',
amount_usdt DECIMAL(20,6) NOT NULL DEFAULT 0,
address_index INTEGER NOT NULL DEFAULT 0,
address VARCHAR(80) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
tx_hash VARCHAR(120) DEFAULT '',
paid_at TIMESTAMP,
confirmed_at TIMESTAMP,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""
)
cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_address_unique ON qd_usdt_orders(chain, address)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id)")
cur.execute("CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status)")
except Exception:
pass
# -------------------- Address derivation --------------------
def _derive_trc20_address_from_xpub(self, xpub: str, index: int) -> str:
"""
Derive TRON address from xpub.
Requires bip_utils.
NOTE:
- Some wallets export account-level xpub at m/44'/195'/0' (level=3).
- Some export change-level xpub at m/44'/195'/0'/0 (level=4, external chain).
This function supports both by normalizing to change-level before AddressIndex().
"""
try:
from bip_utils import Bip44, Bip44Coins, Bip44Changes
except Exception as e:
raise RuntimeError(f"bip_utils_missing:{e}")
if not xpub:
raise RuntimeError("missing_xpub")
if index < 0:
raise RuntimeError("invalid_index")
ctx = Bip44.FromExtendedKey(xpub, Bip44Coins.TRON)
lvl = int(ctx.Level())
# Normalize to change-level (external chain) so we can derive addresses by index
if lvl == 3:
# account-level xpub: m/44'/195'/0'
ctx = ctx.Change(Bip44Changes.CHAIN_EXT)
elif lvl == 4:
# change-level xpub: m/44'/195'/0'/0
pass
elif lvl == 5:
# address-level xpub: cannot derive other indexes
if index != 0:
raise RuntimeError("xpub_is_address_level")
return ctx.PublicKey().ToAddress()
else:
raise RuntimeError(f"unsupported_xpub_level:{lvl}")
addr = ctx.AddressIndex(index).PublicKey().ToAddress()
return addr
# -------------------- Orders --------------------
def create_order(self, user_id: int, plan: str) -> Tuple[bool, str, Dict[str, Any]]:
cfg = self._get_cfg()
if not cfg["enabled"]:
return False, "usdt_pay_disabled", {}
if cfg["chain"] != "TRC20":
return False, "unsupported_chain", {}
plan = (plan or "").strip().lower()
if plan not in ("monthly", "yearly", "lifetime"):
return False, "invalid_plan", {}
plans = self.billing.get_membership_plans()
amount = Decimal(str(plans.get(plan, {}).get("price_usd") or 0))
if amount <= 0:
return False, "invalid_amount", {}
now = datetime.now(timezone.utc)
expires_at = now + timedelta(minutes=cfg["order_expire_minutes"])
try:
with get_db_connection() as db:
cur = db.cursor()
self._ensure_schema_best_effort(cur)
# allocate next address index (simple monotonic)
cur.execute(
"SELECT COALESCE(MAX(address_index), -1) as max_idx FROM qd_usdt_orders WHERE chain = 'TRC20'"
)
max_idx = cur.fetchone().get("max_idx")
next_idx = int(max_idx) + 1
address = self._derive_trc20_address_from_xpub(cfg["xpub_trc20"], next_idx)
cur.execute(
"""
INSERT INTO qd_usdt_orders
(user_id, plan, chain, amount_usdt, address_index, address, status, expires_at, created_at, updated_at)
VALUES (?, ?, 'TRC20', ?, ?, ?, 'pending', ?, NOW(), NOW())
RETURNING id
""",
(user_id, plan, float(amount), next_idx, address, expires_at),
)
row = cur.fetchone() or {}
order_id = row.get("id")
db.commit()
cur.close()
return True, "success", {
"order_id": order_id,
"plan": plan,
"chain": "TRC20",
"amount_usdt": str(amount),
"address": address,
"expires_at": expires_at.isoformat(),
}
except Exception as e:
logger.error(f"create_order failed: {e}", exc_info=True)
return False, f"error:{str(e)}", {}
def get_order(self, user_id: int, order_id: int, refresh: bool = True) -> Tuple[bool, str, Dict[str, Any]]:
try:
with get_db_connection() as db:
cur = db.cursor()
self._ensure_schema_best_effort(cur)
cur.execute(
"""
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
paid_at, confirmed_at, expires_at, created_at, updated_at
FROM qd_usdt_orders
WHERE id = ? AND user_id = ?
""",
(order_id, user_id),
)
row = cur.fetchone()
if not row:
cur.close()
return False, "order_not_found", {}
if refresh:
self._refresh_order_in_tx(cur, row)
db.commit()
# re-read
cur.execute(
"""
SELECT id, user_id, plan, chain, amount_usdt, address_index, address, status, tx_hash,
paid_at, confirmed_at, expires_at, created_at, updated_at
FROM qd_usdt_orders
WHERE id = ? AND user_id = ?
""",
(order_id, user_id),
)
row = cur.fetchone()
cur.close()
return True, "success", self._row_to_dict(row)
except Exception as e:
logger.error(f"get_order failed: {e}", exc_info=True)
return False, f"error:{str(e)}", {}
def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
return {
"order_id": row.get("id"),
"plan": row.get("plan"),
"chain": row.get("chain"),
"amount_usdt": str(row.get("amount_usdt") or 0),
"address": row.get("address") or "",
"status": row.get("status") or "",
"tx_hash": row.get("tx_hash") or "",
"paid_at": row.get("paid_at").isoformat() if row.get("paid_at") else None,
"confirmed_at": row.get("confirmed_at").isoformat() if row.get("confirmed_at") else None,
"expires_at": row.get("expires_at").isoformat() if row.get("expires_at") else None,
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
}
# -------------------- Chain check --------------------
def _refresh_order_in_tx(self, cur, row: Dict[str, Any]) -> None:
cfg = self._get_cfg()
status = (row.get("status") or "").lower()
chain = (row.get("chain") or "").upper()
expires_at = row.get("expires_at")
now = datetime.now(timezone.utc)
if expires_at and isinstance(expires_at, datetime):
exp = expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if status == "pending" and exp <= now:
cur.execute("UPDATE qd_usdt_orders SET status = 'expired', updated_at = NOW() WHERE id = ?", (row["id"],))
return
if chain != "TRC20":
return
if status not in ("pending", "paid"):
return
address = row.get("address") or ""
amount = Decimal(str(row.get("amount_usdt") or 0))
if not address or amount <= 0:
return
tx = self._find_trc20_usdt_incoming(address, amount, row.get("created_at"))
if not tx:
return
tx_hash = tx.get("transaction_id") or ""
paid_at = datetime.now(timezone.utc)
cur.execute(
"UPDATE qd_usdt_orders SET status = 'paid', tx_hash = ?, paid_at = ?, updated_at = NOW() WHERE id = ? AND status = 'pending'",
(tx_hash, paid_at, row["id"]),
)
# Confirm after a short delay to reduce reorg/uncle risk (TRON usually stable)
# If already old enough, confirm now.
confirm_sec = int(cfg.get("confirm_seconds") or 30)
try:
if confirm_sec <= 0:
confirm_sec = 0
# If transaction timestamp is available, use it
tx_ts = tx.get("block_timestamp")
if tx_ts:
tx_time = datetime.fromtimestamp(int(tx_ts) / 1000.0, tz=timezone.utc)
if (now - tx_time).total_seconds() >= confirm_sec:
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
else:
# no timestamp -> confirm immediately
self._confirm_and_activate_in_tx(cur, row["id"], row.get("user_id"), row.get("plan"), tx_hash)
except Exception:
# do not block
pass
def _confirm_and_activate_in_tx(self, cur, order_id: int, user_id: int, plan: str, tx_hash: str) -> None:
# Mark confirmed if not already
cur.execute(
"UPDATE qd_usdt_orders SET status='confirmed', confirmed_at = NOW(), updated_at = NOW() WHERE id = ? AND status IN ('paid','pending')",
(order_id,),
)
# Activate membership (idempotent-ish: billing_service stacks vip)
try:
# We use existing membership activation (writes qd_membership_orders + credits logs).
ok, msg, data = self.billing.purchase_membership(int(user_id), str(plan))
logger.info(f"USDT activate membership: order={order_id} user={user_id} plan={plan} ok={ok} msg={msg}")
except Exception as e:
logger.error(f"USDT activate membership failed: order={order_id} err={e}", exc_info=True)
def _find_trc20_usdt_incoming(self, address: str, amount_usdt: Decimal, created_at: Optional[datetime]) -> Optional[Dict[str, Any]]:
cfg = self._get_cfg()
base = cfg["trongrid_base"]
contract = cfg["usdt_trc20_contract"]
url = f"{base}/v1/accounts/{address}/transactions/trc20"
headers = {}
if cfg["trongrid_key"]:
headers["TRON-PRO-API-KEY"] = cfg["trongrid_key"]
params = {
"only_to": "true",
"limit": 50,
"contract_address": contract,
}
try:
resp = requests.get(url, params=params, headers=headers, timeout=10)
if resp.status_code != 200:
return None
data = resp.json() or {}
items = data.get("data") or []
# TRC20 USDT has 6 decimals
target = int((amount_usdt * Decimal("1000000")).to_integral_value())
min_ts = None
if created_at and isinstance(created_at, datetime):
ct = created_at
if ct.tzinfo is None:
ct = ct.replace(tzinfo=timezone.utc)
min_ts = int(ct.timestamp() * 1000) - 60_000
for it in items:
try:
if it.get("to") != address:
continue
if min_ts and int(it.get("block_timestamp") or 0) < min_ts:
continue
val = int(it.get("value") or 0)
if val != target:
continue
# basic checks
token = it.get("token_info") or {}
if str(token.get("symbol") or "").upper() != "USDT":
# some APIs omit symbol; contract filter should already ensure
pass
return it
except Exception:
continue
except Exception:
return None
return None
_svc = None
def get_usdt_payment_service() -> UsdtPaymentService:
global _svc
if _svc is None:
_svc = UsdtPaymentService()
return _svc