Initial commit

This commit is contained in:
freedakgmail
2026-07-17 18:49:07 +08:00
commit 9c7d7abdd4
100 changed files with 41337 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Services 模块
+253
View File
@@ -0,0 +1,253 @@
"""
豆包AI服务模块
用于股票分析的AI对话(流式输出版本)
"""
import requests
import json
# API配置
API_KEY = "9fd8383f-5776-4366-855d-c6f40e867940"
API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"
MODEL = "doubao-seed-1-6-251015"
def analyze_stock(stock_code, stock_name, stock_data):
"""
使用豆包AI分析股票(流式输出,获取思考和结论)
"""
# 构建分析提示词(基于推荐模型v3.0)
fund_flow_str = format_fund_flow(stock_data.get('fund_flow_3days', []))
prompt = f"""分析{stock_name}({stock_code})投资价值。
数据:价格{stock_data.get('price', 'N/A')}元,PE={stock_data.get('pe', 'N/A')}PB={stock_data.get('pb', 'N/A')}ROE={stock_data.get('roe', 'N/A')}%,市值{format_market_cap(stock_data.get('total_market_cap'))}
资金流向:{fund_flow_str}
评分模型:价格位置(30分,低于50%为低位)、资金流向(25分,主力净流入占比)、趋势(15分)、连续性(15分,≥3天强连续)、涨跌配合(10分)、量能(5分)
推荐率:80-100强买/卖,60-79可操作,40-59观望,0-39不建议
按以下格式输出:
## 技术面分析
分析资金流向趋势和连续性
## 基本面分析
分析PE/ROE估值和盈利能力
## 操作建议
明确建议买入/持有/卖出,给出预估推荐率(0-100)
## 风险提示
列出2-3个风险点"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": MODEL,
"max_completion_tokens": 2048,
"stream": True, # 启用流式输出
"messages": [
{
"role": "user",
"content": prompt
}
]
}
# 流式请求
response = requests.post(API_URL, headers=headers, json=payload, timeout=120, stream=True)
if response.status_code == 200:
reasoning_content = "" # 思考过程
content = "" # 最终结论
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
data_str = line_str[6:]
if data_str == '[DONE]':
break
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
# 获取思考过程
if 'reasoning_content' in delta and delta['reasoning_content']:
reasoning_content += delta['reasoning_content']
# 获取最终内容
if 'content' in delta and delta['content']:
content += delta['content']
except json.JSONDecodeError:
continue
# 组合思考过程和结论
full_analysis = ""
if reasoning_content:
full_analysis += "## 💭 AI思考过程\n" + reasoning_content + "\n\n---\n\n"
if content:
full_analysis += content
if full_analysis:
return {'success': True, 'analysis': full_analysis}
else:
return {'success': False, 'error': 'AI返回内容为空'}
else:
return {'success': False, 'error': f'API请求失败: {response.status_code}'}
except requests.exceptions.Timeout:
return {'success': False, 'error': 'AI分析超时,请稍后重试'}
except Exception as e:
return {'success': False, 'error': f'AI分析失败: {str(e)}'}
def analyze_stock_stream(stock_code, stock_name, stock_data):
"""
使用豆包AI分析股票(流式生成器,用于SSE)
"""
# 构建分析提示词
fund_flow_str = format_fund_flow(stock_data.get('fund_flow_3days', []))
signal_status = stock_data.get('signal_status', [])
indicators = stock_data.get('indicators', {})
signal_lines = []
triggered_names = []
for s in signal_status:
status = '✅已触发' if s.get('triggered') else '○未触发'
signal_lines.append(f" {s.get('name','')}: {status} (胜率{s.get('strength',0)}%) — {s.get('description','')}")
if s.get('triggered'):
triggered_names.append(s.get('name', ''))
signal_text = '\n'.join(signal_lines) if signal_lines else ' 暂无扫描数据'
triggered_text = ''.join(triggered_names) if triggered_names else ''
macd = indicators.get('macd', {})
skdj = indicators.get('skdj', {})
ema = indicators.get('ema', {})
indicator_text = f"MACD: DIF={macd.get('dif','N/A')}, DEA={macd.get('dea','N/A')} | SKDJ: K={skdj.get('k','N/A')}, D={skdj.get('d','N/A')} | EMA: EMA3={ema.get('ema3','N/A')}, EMA21={ema.get('ema21','N/A')}"
prompt = f"""基于"交易信号实战体系"分析{stock_name}({stock_code})。
【基本面数据】
价格{stock_data.get('price', 'N/A')}元,PE={stock_data.get('pe', 'N/A')}PB={stock_data.get('pb', 'N/A')}ROE={stock_data.get('roe', 'N/A')}%,市值{format_market_cap(stock_data.get('total_market_cap'))}
资金流向:{fund_flow_str}
【技术指标】
{indicator_text}
【7大交易信号状态】(当前已触发: {triggered_text}
{signal_text}
【交易信号体系规则】
信号胜率排行: ★主升浪85% > 日线底背离80% > 龙抬头75% > 真龙70% > 短底背离65% > 老鼠仓60% > 反弹55%
标准牛股启动顺序: 日线底背离→龙抬头→真龙→★主升浪→反弹
体系最强战法: 1)日线底背离出现→关注 2)龙抬头出现→买入 3)真龙/★主升浪→持有加仓 4)不见主升浪→不出场
请按以下格式分析:
## 交易信号分析
根据7大信号的触发状态,判断该股在"底部→拉升"流程中处于哪个阶段。已触发的信号说明什么?距离下一个关键信号还有多远?
## 基本面分析
简要分析PE/ROE估值水平和盈利能力(2-3句话)
## 操作建议
基于体系最强战法规则,给出明确建议(关注/买入/持有加仓/减仓/观望),并解释理由。给出推荐率(0-100)
## 风险提示
列出2-3个关键风险点"""
try:
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}"
}
payload = {
"model": MODEL,
"max_completion_tokens": 2048,
"stream": True,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(API_URL, headers=headers, json=payload, timeout=120, stream=True)
if response.status_code == 200:
in_reasoning = False
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
data_str = line_str[6:]
if data_str == '[DONE]':
break
try:
data = json.loads(data_str)
if 'choices' in data and len(data['choices']) > 0:
delta = data['choices'][0].get('delta', {})
# 思考过程
if 'reasoning_content' in delta and delta['reasoning_content']:
if not in_reasoning:
yield {'type': 'reasoning_start'}
in_reasoning = True
yield {'type': 'reasoning', 'content': delta['reasoning_content']}
# 最终内容
if 'content' in delta and delta['content']:
if in_reasoning:
yield {'type': 'reasoning_end'}
in_reasoning = False
yield {'type': 'content', 'content': delta['content']}
except json.JSONDecodeError:
continue
if in_reasoning:
yield {'type': 'reasoning_end'}
else:
yield {'type': 'error', 'content': f'API请求失败: {response.status_code}'}
except requests.exceptions.Timeout:
yield {'type': 'error', 'content': 'AI分析超时,请稍后重试'}
except Exception as e:
yield {'type': 'error', 'content': f'AI分析失败: {str(e)}'}
def format_market_cap(value):
"""格式化市值"""
if not value:
return 'N/A'
try:
value = float(value)
if value >= 100000000000: # 千亿
return f"{value/100000000000:.2f}千亿"
elif value >= 100000000: # 亿
return f"{value/100000000:.2f}亿"
else:
return f"{value/10000:.2f}"
except:
return str(value)
def format_fund_flow(fund_flow_list):
"""格式化资金流向数据"""
if not fund_flow_list:
return "暂无数据"
lines = []
for item in fund_flow_list:
date = item.get('date', '')
change = item.get('change_pct', 0)
main = item.get('main_pct', 0)
super_pct = item.get('super_pct', 0)
lines.append(f"- {date}: 涨跌{change:+.2f}%, 主力{main:+.2f}%, 超大单{super_pct:+.2f}%")
return '\n'.join(lines)
+309
View File
@@ -0,0 +1,309 @@
"""
麦蕊智数API服务模块
API文档: https://api.mairuiapi.com
Licence: AEB5CE22-155A-4535-AE01-610920EB2751
"""
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import time
from datetime import datetime, timedelta
# API配置
LICENCE = "5352ED2F-94E5-4E96-8B7F-B57BA75284E3"
BASE_URL = "https://api.mairuiapi.com"
# 缓存配置
_cache = {
'realtime_all': {'data': None, 'timestamp': None, 'ttl': 60}, # 全市场实时数据缓存60秒
}
# 全局连接池 Session(TCP连接复用,大幅减少连接建立开销)
_session = None
def _get_session():
"""获取全局复用的 requests.Session(带连接池和自动重试)"""
global _session
if _session is None:
_session = requests.Session()
retry_strategy = Retry(
total=2, # 最多重试2次
backoff_factor=0.3, # 重试间隔: 0.3s, 0.6s
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(
max_retries=retry_strategy,
pool_connections=20, # 连接池大小
pool_maxsize=20, # 最大连接数
)
_session.mount("https://", adapter)
_session.mount("http://", adapter)
return _session
def _request(url, timeout=10):
"""发送API请求(复用连接池)"""
try:
session = _get_session()
resp = session.get(url, timeout=timeout)
if resp.status_code == 200:
return resp.json()
else:
print(f"API请求失败: {url}, status={resp.status_code}")
return None
except Exception as e:
print(f"API请求异常: {url}, error={e}")
return None
# ========== 实时交易数据 ==========
def get_realtime_price(stock_code):
"""
获取单只股票实时交易数据(券商数据源)
API: https://api.mairuiapi.com/hsrl/ssjy/{stock_code}/{licence}
"""
url = f"{BASE_URL}/hsrl/ssjy/{stock_code}/{LICENCE}"
data = _request(url)
if data:
return {
'success': True,
'data': {
'code': stock_code,
'price': float(data.get('p', 0)),
'change': float(data.get('pc', 0)),
'open': float(data.get('o', 0)),
'high': float(data.get('h', 0)),
'low': float(data.get('l', 0)),
'volume': float(data.get('v', 0)),
'amount': float(data.get('cje', 0)),
'pe': float(data.get('pe', 0)) if data.get('pe') else None,
'pb': float(data.get('sjl', 0)) if data.get('sjl') else None,
'turnover': float(data.get('hs', 0)),
'total_market_cap': float(data.get('sz', 0)),
'circulating_market_cap': float(data.get('lt', 0)),
'update_time': data.get('t', ''),
}
}
return {'success': False, 'error': '获取失败'}
def get_realtime_prices_batch(stock_codes):
"""
批量获取实时交易数据(最多20只)
API: https://api.mairuiapi.com/hsrl/ssjy_more/{licence}?stock_codes=xxx,xxx
"""
if not stock_codes:
return {}
# 每次最多20只
codes_str = ','.join(stock_codes[:20])
url = f"{BASE_URL}/hsrl/ssjy_more/{LICENCE}?stock_codes={codes_str}"
data = _request(url)
results = {}
if data and isinstance(data, list):
for i, item in enumerate(data):
if i < len(stock_codes):
code = stock_codes[i]
results[code] = {
'code': code,
'price': float(item.get('p', 0)),
'change': float(item.get('pc', 0)),
'pe': float(item.get('pe', 0)) if item.get('pe') else None,
'pb': float(item.get('pb_ratio', 0)) if item.get('pb_ratio') else None,
}
return results
# ========== K线数据 ==========
def get_kline(stock_code, period='d', days=30, adjust='f'):
"""
获取K线数据
API: https://api.mairuiapi.com/hsstock/history/{code}.{market}/{period}/{adjust}/{licence}
参数:
- period: 5/15/30/60/d/w/m/y (分钟/日/周/月/年)
- adjust: n(不复权)/f(前复权)/b(后复权)
"""
# 确定市场
if stock_code.startswith(('0', '3')):
market = 'SZ'
elif stock_code.startswith(('8', '9')):
market = 'BJ'
else:
market = 'SH'
# 计算日期范围
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
url = f"{BASE_URL}/hsstock/history/{stock_code}.{market}/{period}/{adjust}/{LICENCE}?st={start_date}&et={end_date}"
data = _request(url)
if data and isinstance(data, list):
kline_data = []
for item in data:
# 处理日期格式,去掉时间部分
date_str = item.get('t', '')
if date_str and ' ' in date_str:
date_str = date_str.split(' ')[0] # 只保留日期部分
kline_data.append({
'date': date_str,
'open': float(item.get('o', 0)),
'high': float(item.get('h', 0)),
'low': float(item.get('l', 0)),
'close': float(item.get('c', 0)),
'volume': float(item.get('v', 0)),
'amount': float(item.get('a', 0)),
})
return {'success': True, 'data': kline_data}
return {'success': True, 'data': []}
# ========== 公司信息 ==========
def get_company_info(stock_code):
"""
获取公司简介
API: https://api.mairuiapi.com/hscp/gsjj/{stock_code}/{licence}
"""
url = f"{BASE_URL}/hscp/gsjj/{stock_code}/{LICENCE}"
data = _request(url)
if data:
return {
'success': True,
'data': {
'name': data.get('name', ''),
'industry': data.get('idea', '').split(',')[0] if data.get('idea') else '',
'list_date': data.get('ldate', ''),
'issue_price': data.get('sprice', ''),
'description': data.get('desc', ''),
'business_scope': data.get('bscope', ''),
}
}
return {'success': False, 'error': '获取失败'}
# ========== 财务指标 ==========
def get_financial_indicators(stock_code):
"""
获取财务指标
API: https://api.mairuiapi.com/hscp/cwzb/{stock_code}/{licence}
"""
url = f"{BASE_URL}/hscp/cwzb/{stock_code}/{LICENCE}"
data = _request(url)
if data and isinstance(data, list) and len(data) > 0:
latest = data[0] # 最新一期
return {
'success': True,
'data': {
'report_date': latest.get('date', ''),
'eps': _parse_float(latest.get('tbmg')), # 摊薄每股收益
'bps': _parse_float(latest.get('mgjz')), # 每股净资产
'roe': _parse_float(latest.get('jzsy')), # 净资产收益率
'gross_margin': _parse_float(latest.get('xsml')), # 销售毛利率
'net_margin': _parse_float(latest.get('xsjl')), # 销售净利率
'revenue_yoy': _parse_float(latest.get('zysr')), # 主营业务收入增长率
'profit_yoy': _parse_float(latest.get('jlzz')), # 净利润增长率
'debt_ratio': _parse_float(latest.get('zcfzl')), # 资产负债率
'current_ratio': _parse_float(latest.get('ldbl')), # 流动比率
}
}
return {'success': False, 'error': '获取失败'}
def _parse_float(value):
"""解析浮点数"""
if value is None:
return None
try:
return float(value)
except:
return None
# ========== 资金流向 ==========
def get_fund_flow(stock_code, days=3):
"""
获取资金流向数据
API: https://api.mairuiapi.com/hsstock/history/transaction/{stock_code}/{licence}?lt={days}
"""
url = f"{BASE_URL}/hsstock/history/transaction/{stock_code}/{LICENCE}?lt={days}"
data = _request(url)
if data and isinstance(data, list):
flow_data = []
for item in data:
# 计算主力净流入 = 主买大单+主买特大单 - 主卖大单-主卖特大单
main_buy = float(item.get('zmbddcje', 0)) + float(item.get('zmbtdcje', 0))
main_sell = float(item.get('zmsddcje', 0)) + float(item.get('zmstdcje', 0))
main_net = main_buy - main_sell
flow_data.append({
'date': datetime.fromtimestamp(item.get('t', 0)).strftime('%Y-%m-%d') if item.get('t') else '',
'main_net_inflow': main_net,
'super_buy': float(item.get('zmbtdcje', 0)),
'super_sell': float(item.get('zmstdcje', 0)),
'big_buy': float(item.get('zmbddcje', 0)),
'big_sell': float(item.get('zmsddcje', 0)),
})
return {'success': True, 'data': flow_data}
return {'success': True, 'data': []}
# ========== 股票列表 ==========
def get_stock_list():
"""
获取股票列表
API: https://api.mairuiapi.com/hslt/list/{licence}
"""
url = f"{BASE_URL}/hslt/list/{LICENCE}"
data = _request(url, timeout=30)
if data and isinstance(data, list):
return {
'success': True,
'data': [{'code': item.get('dm'), 'name': item.get('mc'), 'market': item.get('jys')} for item in data]
}
return {'success': False, 'error': '获取失败'}
# ========== 涨停股池 ==========
def get_limit_up_stocks(date=None):
"""
获取涨停股池
API: https://api.mairuiapi.com/hslt/ztgc/{date}/{licence}
"""
if date is None:
date = datetime.now().strftime('%Y-%m-%d')
url = f"{BASE_URL}/hslt/ztgc/{date}/{LICENCE}"
data = _request(url)
if data and isinstance(data, list):
return {
'success': True,
'data': [{
'code': item.get('dm'),
'name': item.get('mc'),
'price': float(item.get('p', 0)),
'change': float(item.get('zf', 0)),
'amount': float(item.get('cje', 0)),
'limit_count': int(item.get('lbc', 0)),
'first_limit_time': item.get('fbt', ''),
'industry': item.get('hy', ''),
} for item in data]
}
return {'success': True, 'data': []}
+637
View File
@@ -0,0 +1,637 @@
"""
模拟交易定时任务调度器
- 交易日09:35自动执行买入(v7最优买入时点)
- 交易日13:40自动执行卖出(v7最优卖出时点)
- 交易日15:05更新持仓价格
"""
import threading
import time
from datetime import datetime, date, timedelta
from concurrent.futures import ThreadPoolExecutor, as_completed
import schedule
from services.stock_algorithms import compute_recommend, get_latest_price
# 全局变量
_scheduler_thread = None
_is_running = False
# ═══════════════════════════════════════════════════════
# 动态交易日历缓存
# 通过 akshare 从新浪财经自动获取A股交易日历
# 包含所有历史及未来交易日,自动适配节假日
# ═══════════════════════════════════════════════════════
_trading_dates_cache = set() # 交易日集合 (date objects)
_cache_loaded_date = None # 缓存加载日期,每天最多刷新1次
def _load_trading_calendar():
"""从新浪财经加载A股交易日历到内存缓存"""
global _trading_dates_cache, _cache_loaded_date
try:
import akshare as ak
df = ak.tool_trade_date_hist_sina()
if df is not None and not df.empty:
new_cache = set()
for val in df['trade_date']:
if isinstance(val, date):
new_cache.add(val)
else:
# 字符串格式 'YYYY-MM-DD'
new_cache.add(date.fromisoformat(str(val)))
_trading_dates_cache = new_cache
_cache_loaded_date = date.today()
print(f"[交易日历] 加载成功: {len(_trading_dates_cache)} 个交易日 "
f"(范围: {min(_trading_dates_cache)} ~ {max(_trading_dates_cache)})")
return True
except Exception as e:
print(f"[交易日历] 从新浪获取交易日历失败: {e}")
return False
def _ensure_calendar_loaded():
"""确保交易日历已加载且是最新的(每天自动刷新一次)"""
global _cache_loaded_date
today = date.today()
if _trading_dates_cache and _cache_loaded_date == today:
return True # 缓存有效
# 需要加载/刷新
return _load_trading_calendar()
def is_trading_day(check_date=None):
"""判断是否为A股交易日(基于新浪交易日历,自动适配全部节假日)"""
if check_date is None:
check_date = date.today()
# 快速检查:周末一定不是交易日
if check_date.weekday() >= 5:
return False
# 尝试使用动态交易日历
if _ensure_calendar_loaded() and _trading_dates_cache:
# 检查日期是否超出日历范围(日历通常只覆盖到当年年底)
max_cal_date = max(_trading_dates_cache)
if check_date > max_cal_date:
print(f"[定时任务] ⚠️ {check_date} 超出日历范围({max_cal_date}),按工作日处理")
return True # 超出范围的工作日默认视为交易日
if check_date in _trading_dates_cache:
return True
else:
print(f"[定时任务] {check_date} 不在交易日历中,非交易日")
return False
# 降级:日历加载失败时,工作日默认视为交易日(避免误跳过)
print(f"[定时任务] ⚠️ 交易日历不可用,{check_date} 按工作日处理")
return True
def is_trading_time():
"""判断当前是否在交易时间内"""
now = datetime.now()
hour = now.hour
minute = now.minute
time_val = hour * 100 + minute
# 交易时间:9:30-11:30, 13:00-15:00
if (930 <= time_val <= 1130) or (1300 <= time_val <= 1500):
return True
return False
def get_all_users():
"""获取所有启用自动交易的用户"""
from db import get_db
from psycopg2.extras import RealDictCursor
conn = get_db()
if not conn:
return []
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
cur.execute("""
SELECT u.id as user_id, u.username, c.trade_quantity
FROM users u
LEFT JOIN sim_config c ON u.id = c.user_id
WHERE c.auto_trade_enabled = true OR c.auto_trade_enabled IS NULL
""")
return cur.fetchall()
except Exception as e:
print(f"[定时任务] 获取用户列表失败: {e}")
return []
finally:
conn.close()
# 自定义股票列表(100只精选股票)
CUSTOM_STOCKS = [
'000001', '000002', '000063', '000100', '000157', '000333', '000338', '000425', '000538', '000568',
'000596', '000625', '000651', '000661', '000703', '000725', '000768', '000776', '000858', '000876',
'002007', '002024', '002027', '002049', '002120', '002142', '002179', '002230', '002236', '002241',
'002271', '002304', '002352', '002371', '002415', '002460', '002466', '002475', '002493', '002555',
'002594', '002602', '002607', '002624', '002714', '002736', '002812', '002841', '002916', '002938',
'300003', '300014', '300015', '300033', '300059', '300122', '300124', '300136', '300142', '300144',
'300347', '300408', '300433', '300496', '300498', '300502', '300529', '300558', '300601', '300628',
'300750', '300760', '300782', '300896', '300948', '600000', '600009', '600016', '600028', '600030',
'600036', '600048', '600050', '600061', '600104', '600111', '600115', '600132', '600150', '600196',
'600276', '600309', '600332', '600346', '600352', '600362', '600406', '600436', '600519', '600585'
]
def get_hot_stocks(limit=50):
"""获取热门股票列表(自定义100只精选股票)
东方财富人气榜API已不可用(腾讯云封锁),仅使用自定义列表
"""
stocks = []
seen_codes = set()
for code in CUSTOM_STOCKS:
if code not in seen_codes:
stocks.append({'code': code, 'name': ''})
seen_codes.add(code)
print(f"[定时任务] 自定义精选 {len(stocks)} 只股票待扫描")
return stocks
def _compute_recommend(signal_status, indicators, triggered_count, is_holding):
"""统一推荐逻辑 — 委托给 services.stock_algorithms.compute_recommend"""
return compute_recommend(signal_status, indicators, triggered_count, is_holding)
def execute_auto_trade_for_user(user_id, trade_quantity=1000, scan_date=None):
"""基于统一推荐算法的自动交易(与全景扫描推荐使用完全相同的逻辑)
买入: _compute_recommend 返回 '买入' 的股票(主升浪/底背离+龙抬头)
加仓: _compute_recommend 返回 '加仓' 的持仓股(主升浪信号)
卖出: _compute_recommend 返回 '卖出' 的持仓股(MACD死叉)
scan_date: 使用哪天的扫描数据, None则自动选择最近可用的
"""
from db import get_db
from psycopg2.extras import RealDictCursor
print(f"[定时任务] 开始为用户{user_id}执行策略交易(统一推荐算法)...")
conn = get_db()
if not conn:
return {'error': '数据库连接失败'}
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
today = date.today()
now = datetime.now().time()
results = []
# 1. 获取用户持仓
cur.execute("""
SELECT stock_code, stock_name, quantity, avg_cost::float
FROM sim_positions WHERE user_id = %s AND quantity > 0
""", (user_id,))
positions = cur.fetchall()
holding_codes = {p['stock_code'] for p in positions}
# 2. 读取扫描结果(指定日期或自动查找最近可用的)
if scan_date:
cur.execute("""
SELECT code, name, triggered_count, signal_status, indicators
FROM stock_signal_scan WHERE scan_date = %s
""", (scan_date,))
else:
cur.execute("""
SELECT code, name, triggered_count, signal_status, indicators
FROM stock_signal_scan
WHERE scan_date = (
SELECT MAX(scan_date) FROM stock_signal_scan
WHERE scan_date <= %s
)
""", (today,))
scan_rows = cur.fetchall()
scan_map = {r['code']: r for r in scan_rows}
used_date = scan_date or '最近'
if not scan_map:
print(f"[定时任务] 无可用扫描数据(scan_date={used_date}),跳过交易")
return {'success': True, 'results': [], 'message': '无可用扫描数据'}
print(f"[定时任务] 使用扫描数据: {used_date}, 共{len(scan_map)}只股票")
# ===== 卖出逻辑 =====
# 持仓股: 使用 _compute_recommend(is_holding=True) 判断卖出
for pos in positions:
code = pos['stock_code']
scan = scan_map.get(code)
if not scan:
continue
signal_type, display, reason, rate = _compute_recommend(
scan['signal_status'], scan['indicators'],
scan['triggered_count'], is_holding=True
)
if signal_type == 'sell':
price = _get_latest_price(code)
if not price or price <= 0:
continue
qty = min(trade_quantity, pos['quantity'])
realized_pnl = (price - pos['avg_cost']) * qty
cur.execute("""
INSERT INTO sim_trades
(user_id, stock_code, stock_name, trade_type, price, quantity,
trade_date, trade_time, recommend_rate, signal_reason)
VALUES (%s, %s, %s, 'sell', %s, %s, %s, %s, %s, %s)
""", (user_id, code, pos['stock_name'], price, qty,
today, now, rate, reason))
new_qty = pos['quantity'] - qty
if new_qty > 0:
cur.execute("""
UPDATE sim_positions SET
quantity=%s, total_cost=%s, current_price=%s, updated_at=NOW()
WHERE user_id=%s AND stock_code=%s
""", (new_qty, pos['avg_cost'] * new_qty, price, user_id, code))
else:
cur.execute("""
UPDATE sim_positions SET
quantity=0, total_cost=0, current_price=%s, updated_at=NOW()
WHERE user_id=%s AND stock_code=%s
""", (price, user_id, code))
cur.execute("""
INSERT INTO sim_daily_stats (user_id, stat_date, realized_profit, trade_count)
VALUES (%s, %s, %s, 1)
ON CONFLICT (user_id, stat_date) DO UPDATE SET
realized_profit = sim_daily_stats.realized_profit + %s,
trade_count = sim_daily_stats.trade_count + 1
""", (user_id, today, realized_pnl, realized_pnl))
results.append({
'type': 'sell', 'code': code, 'name': pos['stock_name'],
'price': price, 'quantity': qty, 'pnl': realized_pnl, 'reason': reason
})
print(f"[策略交易] 卖出 {code} {pos['stock_name']} {qty}股@{price} | {reason}")
# ===== 买入逻辑 =====
# 非持仓股: 使用 _compute_recommend(is_holding=False) 判断买入
buy_candidates = []
for code, scan in scan_map.items():
if code in holding_codes:
continue
signal_type, display, reason, rate = _compute_recommend(
scan['signal_status'], scan['indicators'],
scan['triggered_count'], is_holding=False
)
if signal_type == 'buy':
buy_candidates.append({
'code': code, 'name': scan['name'] or '',
'recommend_rate': rate,
'reason': reason,
})
# 按推荐率降序排序,取top 3
buy_candidates.sort(key=lambda x: x['recommend_rate'], reverse=True)
buy_candidates = buy_candidates[:3]
for cand in buy_candidates:
code = cand['code']
cur.execute("""
SELECT COUNT(*) as cnt FROM sim_trades
WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy'
""", (user_id, code, today))
if cur.fetchone()['cnt'] > 0:
continue
price = _get_latest_price(code)
if not price or price <= 0:
continue
cur.execute("""
INSERT INTO sim_trades
(user_id, stock_code, stock_name, trade_type, price, quantity,
trade_date, trade_time, recommend_rate, signal_reason)
VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s)
""", (user_id, code, cand['name'], price, trade_quantity,
today, now, cand['recommend_rate'], cand['reason']))
cur.execute("""
INSERT INTO sim_positions
(user_id, stock_code, stock_name, quantity, avg_cost, total_cost, current_price)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, stock_code) DO UPDATE SET
quantity = sim_positions.quantity + EXCLUDED.quantity,
total_cost = sim_positions.total_cost + EXCLUDED.total_cost,
avg_cost = (sim_positions.total_cost + EXCLUDED.total_cost) /
(sim_positions.quantity + EXCLUDED.quantity),
current_price = EXCLUDED.current_price,
stock_name = COALESCE(EXCLUDED.stock_name, sim_positions.stock_name),
updated_at = NOW()
""", (user_id, code, cand['name'], trade_quantity, price,
price * trade_quantity, price))
results.append({
'type': 'buy', 'code': code, 'name': cand['name'],
'price': price, 'quantity': trade_quantity, 'reason': cand['reason']
})
print(f"[策略交易] 买入 {code} {cand['name']} {trade_quantity}股@{price} | {cand['reason']}")
# ===== 加仓逻辑 =====
# 持仓股: 使用 _compute_recommend(is_holding=True) 判断加仓
cur.execute("""
SELECT stock_code, stock_name, quantity, avg_cost::float
FROM sim_positions WHERE user_id = %s AND quantity > 0
""", (user_id,))
current_positions = cur.fetchall()
for pos in current_positions:
code = pos['stock_code']
scan = scan_map.get(code)
if not scan:
continue
signal_type, display, reason, rate = _compute_recommend(
scan['signal_status'], scan['indicators'],
scan['triggered_count'], is_holding=True
)
if display != '加仓':
continue
cur.execute("""
SELECT COUNT(*) as cnt FROM sim_trades
WHERE user_id=%s AND stock_code=%s AND trade_date=%s AND trade_type='buy'
""", (user_id, code, today))
if cur.fetchone()['cnt'] > 0:
continue
price = _get_latest_price(code)
if not price or price <= 0:
continue
add_qty = trade_quantity // 2
cur.execute("""
INSERT INTO sim_trades
(user_id, stock_code, stock_name, trade_type, price, quantity,
trade_date, trade_time, recommend_rate, signal_reason)
VALUES (%s, %s, %s, 'buy', %s, %s, %s, %s, %s, %s)
""", (user_id, code, pos['stock_name'], price, add_qty,
today, now, rate, reason))
cur.execute("""
UPDATE sim_positions SET
quantity = quantity + %s,
total_cost = total_cost + %s,
avg_cost = (total_cost + %s) / (quantity + %s),
current_price = %s,
updated_at = NOW()
WHERE user_id = %s AND stock_code = %s
""", (add_qty, price * add_qty, price * add_qty, add_qty,
price, user_id, code))
results.append({
'type': 'buy', 'code': code, 'name': pos['stock_name'],
'price': price, 'quantity': add_qty, 'reason': reason
})
print(f"[策略交易] 加仓 {code} {pos['stock_name']} {add_qty}股@{price} | {reason}")
conn.commit()
buy_count = len([r for r in results if r['type'] == 'buy'])
sell_count = len([r for r in results if r['type'] == 'sell'])
print(f"[策略交易] 用户{user_id}完成: 买入{buy_count}笔, 卖出{sell_count}")
return {'success': True, 'results': results}
except Exception as e:
conn.rollback()
import traceback
traceback.print_exc()
return {'error': str(e)}
finally:
conn.close()
def _get_latest_price(stock_code):
"""获取股票最新价格 — 委托给 services.stock_algorithms.get_latest_price"""
return get_latest_price(stock_code)
def update_positions_price_for_user(user_id):
"""更新用户持仓的当前价格(收盘时调用)— 使用腾讯财经API"""
from db import get_db
from psycopg2.extras import RealDictCursor
conn = get_db()
if not conn:
return
try:
cur = conn.cursor(cursor_factory=RealDictCursor)
# 获取持仓
cur.execute("""
SELECT stock_code FROM sim_positions
WHERE user_id = %s AND quantity > 0
""", (user_id,))
positions = cur.fetchall()
# 批量获取持仓股票的实时价格(使用腾讯财经API,兼容腾讯云)
codes = [pos['stock_code'] for pos in positions]
if codes:
try:
import requests as _req
tencent_codes = []
for c in codes:
if c.startswith('6'):
tencent_codes.append(f'sh{c}')
else:
tencent_codes.append(f'sz{c}')
_r = _req.get(f'http://qt.gtimg.cn/q={",".join(tencent_codes)}',
timeout=10, headers={'Referer': 'https://finance.qq.com'})
if _r.status_code == 200:
for line in _r.text.strip().split(';'):
if '\"' not in line:
continue
fields = line.split('\"')[1].split('~')
if len(fields) > 3 and fields[3]:
stock_code = fields[2]
price = float(fields[3])
if price > 0:
cur.execute("""
UPDATE sim_positions SET
current_price = %s, updated_at = NOW()
WHERE user_id = %s AND stock_code = %s
""", (price, user_id, stock_code))
except Exception as e:
print(f"[定时任务] 腾讯API批量更新价格失败: {e}")
# 更新每日统计
today = date.today()
cur.execute("""
SELECT
COALESCE(SUM(quantity * current_price), 0) as market_value,
COALESCE(SUM(total_cost), 0) as total_cost,
COALESCE(SUM(quantity * current_price - total_cost), 0) as unrealized
FROM sim_positions
WHERE user_id = %s AND quantity > 0
""", (user_id,))
stats = cur.fetchone()
cur.execute("""
INSERT INTO sim_daily_stats
(user_id, stat_date, total_market_value, total_cost, unrealized_profit)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (user_id, stat_date) DO UPDATE SET
total_market_value = EXCLUDED.total_market_value,
total_cost = EXCLUDED.total_cost,
unrealized_profit = EXCLUDED.unrealized_profit
""", (user_id, today, stats['market_value'], stats['total_cost'], stats['unrealized']))
conn.commit()
print(f"[定时任务] 用户{user_id}持仓价格已更新")
except Exception as e:
conn.rollback()
print(f"[定时任务] 更新用户{user_id}持仓价格失败: {e}")
finally:
conn.close()
def job_morning_trade():
"""早盘交易任务(09:35执行 — v7最优买入时点)
使用昨天收盘后的全景扫描数据做买入/卖出决策
优先使用智能引擎(smart_trade_engine),降级到旧引擎(execute_auto_trade_for_user)
"""
print(f"[定时任务] ===== 早盘交易任务开始 {datetime.now()} =====")
if not is_trading_day():
print("[定时任务] 今天不是交易日,跳过")
return
users = get_all_users()
print(f"[定时任务] 找到{len(users)}个用户需要执行自动交易")
for user in users:
user_id = user['user_id']
# 尝试使用智能引擎
try:
from services.smart_trade_engine import execute_smart_trade
from db import get_db
conn = get_db()
if conn:
result = execute_smart_trade(conn, user_id, scan_date=None)
conn.close()
if result.get('success'):
print(f"[定时任务] 用户{user_id} 智能引擎执行成功 "
f"(算法:{result.get('algo','?')}, 信号:{result.get('signals',0)})")
continue
except Exception as e:
print(f"[定时任务] 用户{user_id} 智能引擎异常,降级到旧引擎: {e}")
# 降级:使用旧引擎
trade_quantity = user.get('trade_quantity') or 1000
execute_auto_trade_for_user(user_id, trade_quantity, scan_date=None)
print(f"[定时任务] ===== 早盘交易任务结束 {datetime.now()} =====")
def job_afternoon_trade():
"""午后交易任务(13:40执行 — v7最优卖出时点)
使用当天中午的全景扫描数据做买入/卖出决策
"""
print(f"[定时任务] ===== 午后交易任务开始 {datetime.now()} =====")
if not is_trading_day():
print("[定时任务] 今天不是交易日,跳过")
return
users = get_all_users()
# 1. 先执行自动交易(使用今天中午11:50生成的扫描数据)
today = date.today()
print(f"[定时任务] 找到{len(users)}个用户需要执行午后自动交易")
for user in users:
user_id = user['user_id']
# 尝试使用智能引擎
try:
from services.smart_trade_engine import execute_smart_trade
from db import get_db
conn = get_db()
if conn:
result = execute_smart_trade(conn, user_id, scan_date=today)
conn.close()
if result.get('success'):
print(f"[定时任务] 用户{user_id} 午后智能引擎执行成功")
continue
except Exception as e:
print(f"[定时任务] 用户{user_id} 智能引擎异常,降级: {e}")
trade_quantity = user.get('trade_quantity') or 1000
execute_auto_trade_for_user(user_id, trade_quantity, scan_date=today)
# 2. 更新持仓价格
print(f"[定时任务] 更新{len(users)}个用户持仓价格")
for user in users:
user_id = user['user_id']
update_positions_price_for_user(user_id)
print(f"[定时任务] ===== 午后交易任务结束 {datetime.now()} =====")
def run_scheduler():
"""运行定时任务调度器"""
global _is_running
# 设置定时任务 — v7最优时点: 09:35买入 / 13:40卖出
schedule.every().day.at("09:35").do(job_morning_trade)
schedule.every().day.at("13:40").do(job_afternoon_trade)
schedule.every().day.at("15:05").do(trigger_closing_update) # 收盘更新持仓价格
print("[定时任务] 调度器已启动 (v7最优时点)")
print("[定时任务] - 09:35 早盘交易(使用昨日扫描数据 — 最优买入时点)")
print("[定时任务] - 13:40 午后交易(使用当日中午扫描数据 — 最优卖出时点)")
print("[定时任务] - 15:05 收盘更新持仓价格")
_is_running = True
while _is_running:
schedule.run_pending()
time.sleep(30) # 每30秒检查一次
def start_scheduler():
"""启动定时任务调度器(在后台线程中运行)"""
global _scheduler_thread, _is_running
if _scheduler_thread is not None and _scheduler_thread.is_alive():
print("[定时任务] 调度器已在运行中")
return
_scheduler_thread = threading.Thread(target=run_scheduler, daemon=True)
_scheduler_thread.start()
print("[定时任务] 后台调度器线程已启动")
def stop_scheduler():
"""停止定时任务调度器"""
global _is_running
_is_running = False
print("[定时任务] 调度器已停止")
# 手动触发任务(用于测试)
def trigger_morning_trade():
"""手动触发早盘交易任务"""
job_morning_trade()
def trigger_afternoon_trade():
"""手动触发午后交易任务"""
job_afternoon_trade()
def trigger_closing_update():
"""手动触发收盘更新(仅更新持仓价格)"""
from db import get_db
if not is_trading_day():
print("[定时任务] 今天不是交易日,跳过")
return
users = get_all_users()
for user in users:
update_positions_price_for_user(user['user_id'])
+663
View File
@@ -0,0 +1,663 @@
"""
交易信号检测模块(numpy向量化优化版)
实现7个交易信号:主升浪、日线底背离、龙抬头、真龙、短底背离、老鼠仓、反弹
信号按胜率排名:
1. 主升浪 85% - MACD零上金叉
2. 日线底背离 80% - 价格新低但MACD不新低(20日版)
3. 龙抬头 75% - SKDJ超跌金叉
4. 真龙 70% - 趋势启动确认
5. 短底背离 65% - 短周期底背离(10日版)
6. 老鼠仓 60% - 盘中急跌后快速回收
7. 反弹 55% - EMA3上穿EMA21
优化要点:
- 所有信号检测函数使用 .values numpy原生数组替代 pandas .iloc
- numpy arr[i] 访问 ~50nspandas iloc[i] 访问 ~5μs,提升 ~100x
- 底背离函数使用 np.argmin 替代 pandas idxmin
- detect_all_signals 智能跳过已是 float 的类型转换
- _check_all_signal_status 使用 numpy 数组切片替代 pandas 切片
"""
import pandas as pd
import numpy as np
from services.technical_indicators import calc_all_indicators
def detect_main_rising_wave(df, lookback=5):
"""
主升浪信号(胜率85%)— numpy优化版
条件:MACD零上金叉 —— DIF和DEA都在零轴上方,DIF从下往上穿越DEA
含义:趋势走好,进入加速拉升阶段
"""
signals = []
if len(df) < 30:
return signals
dif = df['dif'].values
dea = df['dea'].values
dates = df['date'].values
closes = df['close'].values
n = len(dif)
start = max(1, n - lookback)
for i in range(start, n):
if dif[i] > 0 and dea[i] > 0 and dif[i - 1] <= dea[i - 1] and dif[i] > dea[i]:
signals.append({
'date': str(dates[i]),
'type': 'main_rising_wave',
'name': '主升浪',
'direction': 'buy',
'strength': 85,
'price': float(closes[i]),
'description': f"MACD零上金叉: DIF={dif[i]:.3f}, DEA={dea[i]:.3f},进入加速拉升阶段",
})
return signals
def detect_daily_bottom_divergence(df, lookback=5, window=20):
"""
日线底背离信号(胜率80%)— numpy优化版
条件:价格创20日新低,但MACD的DIF未创对应新低
含义:真正跌透,迎来大级别反转
"""
signals = []
if len(df) < window + 10:
return signals
close = df['close'].values.astype(np.float64)
dif = df['dif'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(window, n - lookback)
for i in range(start, n):
window_slice = close[i - window:i + 1]
curr_price = close[i]
price_min = window_slice.min()
if curr_price > price_min * 1.01:
continue
# 当前日必须是窗口内的实际最低点(等价于 idxmin() == index[-1]
if np.argmin(window_slice) == len(window_slice) - 1:
dif_window = dif[i - window:i]
if len(dif_window) == 0:
continue
dif_at_prev_lows = dif_window.min()
curr_dif = dif[i]
if curr_dif > dif_at_prev_lows and curr_dif < 0:
signals.append({
'date': str(dates[i]),
'type': 'daily_bottom_divergence',
'name': '日线底背离',
'direction': 'buy',
'strength': 80,
'price': float(curr_price),
'description': f"价格创{window}日新低,但MACD的DIF未创新低(DIF={curr_dif:.3f}),大级别反转信号",
})
return signals
def detect_dragon_head(df, lookback=5):
"""
龙抬头信号(胜率75%)— numpy优化版
条件:SKDJ的K值从超跌区域(<20)发生金叉(K上穿D),且信号稳定
含义:短线起爆点,反弹稳定性强
"""
signals = []
if len(df) < 20:
return signals
sk = df['skdj_k'].values.astype(np.float64)
sd = df['skdj_d'].values.astype(np.float64)
dates = df['date'].values
closes = df['close'].values
n = len(sk)
start = max(2, n - lookback)
for i in range(start, n):
oversold = sk[i - 1] < 20 or sk[i] < 30
golden_cross = sk[i - 1] <= sd[i - 1] and sk[i] > sd[i]
stable = True
if i >= 3:
recent_k = sk[i - 2:i + 1]
# ddof=1 与 pandas Series.std() 保持一致
stable = np.std(recent_k, ddof=1) < 15
if oversold and golden_cross and stable:
signals.append({
'date': str(dates[i]),
'type': 'dragon_head',
'name': '龙抬头',
'direction': 'buy',
'strength': 75,
'price': float(closes[i]),
'description': f"SKDJ超跌金叉: K={sk[i]:.1f}, D={sd[i]:.1f},短线起爆点",
})
return signals
def detect_true_dragon(df, lookback=5):
"""
真龙信号(胜率70%)— numpy优化版
条件:价格突破MA20,MA5上穿MA20(金叉),MACD柱由负转正,成交量放大
含义:中期趋势刚刚启动
"""
signals = []
if len(df) < 25:
return signals
close = df['close'].values.astype(np.float64)
ma5 = df['ma5'].values.astype(np.float64)
ma20 = df['ma20'].values.astype(np.float64)
macd = df['macd'].values.astype(np.float64)
volume = df['volume'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(2, n - lookback)
for i in range(start, n):
price_above_ma20 = close[i] > ma20[i]
ma5_cross_ma20 = (ma5[i - 1] <= ma20[i - 1]) and (ma5[i] > ma20[i])
ma5_above_ma20 = ma5[i] > ma20[i]
macd_turn_positive = macd[i] > 0 and macd[i - 1] <= 0
vol_start = max(0, i - 10)
vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0
volume_up = volume[i] > vol_avg * 1.2 if vol_avg > 0 else False
conditions_met = sum([price_above_ma20, ma5_cross_ma20 or ma5_above_ma20, macd_turn_positive, volume_up])
if conditions_met >= 3 and price_above_ma20:
desc_parts = []
if ma5_cross_ma20:
desc_parts.append("MA5金叉MA20")
if macd_turn_positive:
desc_parts.append("MACD翻红")
if volume_up:
desc_parts.append("放量")
signals.append({
'date': str(dates[i]),
'type': 'true_dragon',
'name': '真龙',
'direction': 'buy',
'strength': 70,
'price': float(close[i]),
'description': f"趋势启动: {'+'.join(desc_parts)},中期趋势确立",
})
return signals
def detect_short_bottom_divergence(df, lookback=5, window=10):
"""
短底背离信号(胜率65%)— numpy优化版
条件:价格创10日新低,但MACD的DIF未创对应新低
含义:小级别反弹,灵敏度高但力度偏弱
"""
signals = []
if len(df) < window + 5:
return signals
close = df['close'].values.astype(np.float64)
dif = df['dif'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(window, n - lookback)
for i in range(start, n):
window_slice = close[i - window:i + 1]
curr_price = close[i]
price_min = window_slice.min()
if curr_price > price_min * 1.01:
continue
# 当前日必须是窗口内的实际最低点
if np.argmin(window_slice) == len(window_slice) - 1:
dif_window = dif[i - window:i]
if len(dif_window) == 0:
continue
dif_at_prev_lows = dif_window.min()
curr_dif = dif[i]
if curr_dif > dif_at_prev_lows:
signals.append({
'date': str(dates[i]),
'type': 'short_bottom_divergence',
'name': '短底背离',
'direction': 'buy',
'strength': 65,
'price': float(curr_price),
'description': f"价格创{window}日新低,但DIF未新低(DIF={curr_dif:.3f}),小级别反弹信号",
})
return signals
def detect_rat_trading(df, lookback=5):
"""
老鼠仓信号(胜率60%)— numpy优化版
条件:盘中急跌(最低价大幅低于开盘价),但收盘收回(收盘价接近或高于开盘价),且成交量放大
含义:主力偷偷吸筹,上涨不具备即时性
"""
signals = []
if len(df) < 10:
return signals
close = df['close'].values.astype(np.float64)
open_p = df['open'].values.astype(np.float64)
low = df['low'].values.astype(np.float64)
high = df['high'].values.astype(np.float64)
volume = df['volume'].values.astype(np.float64)
dates = df['date'].values
n = len(close)
start = max(1, n - lookback)
for i in range(start, n):
if open_p[i] <= 0:
continue
drop_from_open = (low[i] - open_p[i]) / open_p[i] * 100
hl_diff = high[i] - low[i]
recovery = (close[i] - low[i]) / hl_diff * 100 if hl_diff != 0 else 50.0
close_vs_open = (close[i] - open_p[i]) / open_p[i] * 100
vol_start = max(0, i - 10)
vol_avg = volume[vol_start:i].mean() if i > vol_start else 0.0
volume_up = volume[i] > vol_avg * 1.3 if vol_avg > 0 else False
if drop_from_open < -3 and recovery > 60 and close_vs_open > -1 and volume_up:
signals.append({
'date': str(dates[i]),
'type': 'rat_trading',
'name': '老鼠仓',
'direction': 'buy',
'strength': 60,
'price': float(close[i]),
'description': f"盘中急跌{drop_from_open:.1f}%后回收{recovery:.0f}%,放量吸筹信号",
})
return signals
def detect_rebound(df, lookback=5):
"""
反弹信号(胜率55%)— numpy优化版
条件:EMA3从下向上穿越EMA21
含义:普通均线金叉,震荡市适用、熊市易现假反弹
"""
signals = []
if len(df) < 25:
return signals
ema3 = df['ema3'].values.astype(np.float64)
ema21 = df['ema21'].values.astype(np.float64)
dates = df['date'].values
closes = df['close'].values
n = len(ema3)
start = max(1, n - lookback)
for i in range(start, n):
if ema3[i - 1] <= ema21[i - 1] and ema3[i] > ema21[i]:
signals.append({
'date': str(dates[i]),
'type': 'rebound',
'name': '反弹',
'direction': 'buy',
'strength': 55,
'price': float(closes[i]),
'description': f"EMA3上穿EMA21: EMA3={ema3[i]:.2f}, EMA21={ema21[i]:.2f},均线金叉反弹",
})
return signals
def detect_all_signals(df, lookback=5):
"""
检测所有7个交易信号(优化版)
参数:
df: 包含 date, open, high, low, close, volume 列的DataFrame
lookback: 向后检测的天数(默认检测最近5天)
返回:
dict: {
'signals': [...], # 检测到的所有信号列表
'latest_signals': [...], # 最新一天的信号
'signal_summary': {...}, # 信号统计摘要
'indicators': {...} # 最新技术指标值
}
优化:智能类型转换,已是 float64 的列直接跳过
"""
required_cols = {'date', 'open', 'high', 'low', 'close', 'volume'}
if not required_cols.issubset(set(df.columns)):
missing = required_cols - set(df.columns)
return {'error': f'缺少必要列: {missing}', 'signals': [], 'latest_signals': []}
df = df.copy()
# 智能类型转换:仅在列不是 float 时才做转换(本地DB数据已是 float64,跳过)
for col in ['open', 'high', 'low', 'close', 'volume']:
if not np.issubdtype(df[col].dtype, np.floating):
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0).astype(np.float64)
df = calc_all_indicators(df)
all_signals = []
all_signals.extend(detect_main_rising_wave(df, lookback))
all_signals.extend(detect_daily_bottom_divergence(df, lookback))
all_signals.extend(detect_dragon_head(df, lookback))
all_signals.extend(detect_true_dragon(df, lookback))
all_signals.extend(detect_short_bottom_divergence(df, lookback))
all_signals.extend(detect_rat_trading(df, lookback))
all_signals.extend(detect_rebound(df, lookback))
all_signals.sort(key=lambda x: (-x['strength'], x['date']), reverse=False)
latest_date = str(df['date'].values[-1]) if len(df) > 0 else ''
latest_signals = [s for s in all_signals if s['date'] == latest_date]
signal_summary = {
'total_signals': len(all_signals),
'latest_date': latest_date,
'latest_count': len(latest_signals),
'signal_types': {},
}
for s in all_signals:
t = s['type']
if t not in signal_summary['signal_types']:
signal_summary['signal_types'][t] = 0
signal_summary['signal_types'][t] += 1
indicators = {}
if len(df) > 0:
last = df.iloc[-1]
indicators = {
'macd': {'dif': round(float(last.get('dif', 0)), 4),
'dea': round(float(last.get('dea', 0)), 4),
'macd': round(float(last.get('macd', 0)), 4)},
'skdj': {'k': round(float(last.get('skdj_k', 0)), 2),
'd': round(float(last.get('skdj_d', 0)), 2)},
'kdj': {'k': round(float(last.get('kdj_k', 0)), 2),
'd': round(float(last.get('kdj_d', 0)), 2),
'j': round(float(last.get('kdj_j', 0)), 2)},
'ema': {'ema3': round(float(last.get('ema3', 0)), 2),
'ema21': round(float(last.get('ema21', 0)), 2)},
'ma': {'ma5': round(float(last.get('ma5', 0)), 2),
'ma10': round(float(last.get('ma10', 0)), 2),
'ma20': round(float(last.get('ma20', 0)), 2)},
}
signal_status = _check_all_signal_status(df)
return {
'signals': all_signals,
'latest_signals': latest_signals,
'signal_summary': signal_summary,
'indicators': indicators,
'signal_status': signal_status,
}
def _check_all_signal_status(df):
"""
检查7个信号的当前状态,返回每个信号的就绪程度和说明(numpy优化版)
"""
if len(df) < 30:
return []
n = len(df)
last = df.iloc[-1]
prev = df.iloc[-2] if n > 1 else last
dif = float(last.get('dif', 0))
dea = float(last.get('dea', 0))
macd_val = float(last.get('macd', 0))
prev_dif = float(prev.get('dif', 0))
prev_dea = float(prev.get('dea', 0))
sk = float(last.get('skdj_k', 50))
sd = float(last.get('skdj_d', 50))
prev_sk = float(prev.get('skdj_k', 50))
prev_sd = float(prev.get('skdj_d', 50))
ema3 = float(last.get('ema3', 0))
ema21 = float(last.get('ema21', 0))
prev_ema3 = float(prev.get('ema3', 0))
prev_ema21 = float(prev.get('ema21', 0))
close = float(last.get('close', 0))
open_p = float(last.get('open', 0))
low = float(last.get('low', 0))
high = float(last.get('high', 0))
ma5 = float(last.get('ma5', 0))
ma20 = float(last.get('ma20', 0))
status = []
# 1. 主升浪
above_zero = dif > 0 and dea > 0
golden = prev_dif <= prev_dea and dif > dea
triggered = bool(above_zero and golden)
if triggered:
desc = f"✅ 已触发!DIF={dif:.3f}>0, DEA={dea:.3f}>0, DIF上穿DEA"
elif dif > 0 and dea > 0:
desc = f"DIF和DEA均在零上,等待DIF上穿DEA(差值{dif-dea:.3f}"
elif dif > dea:
desc = f"DIF已在DEA上方,但需等待两者都转正(DIF={dif:.3f}"
else:
desc = f"DIF={dif:.3f}, DEA={dea:.3f},均在零下,距离触发较远"
status.append({
'type': 'main_rising_wave', 'name': '主升浪', 'strength': 85,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness(dif, dea, 'main_rising_wave')
})
# 2. 日线底背离 — 使用numpy数组切片替代pandas切片
close_arr = df['close'].values.astype(np.float64)
dif_arr = df['dif'].values.astype(np.float64)
w20_start = max(0, n - 21)
window_20 = close_arr[w20_start:]
price_min_20 = float(window_20.min())
dif_w20 = dif_arr[w20_start:n - 1] if n > w20_start + 1 else dif_arr[:max(0, n - 1)]
dif_min_20 = float(dif_w20.min()) if len(dif_w20) > 0 else 0.0
at_low = close <= price_min_20 * 1.01
is_actual_min = bool(np.argmin(window_20) == len(window_20) - 1)
dif_diverge = dif > dif_min_20 and dif < 0
triggered = bool(at_low and is_actual_min and dif_diverge)
if triggered:
desc = f"✅ 已触发!价格接近20日新低,DIF({dif:.3f})高于前低({dif_min_20:.3f})"
elif at_low and not is_actual_min:
desc = f"价格接近20日低位({price_min_20:.2f}),但非当前最低点"
elif at_low:
desc = f"价格在20日低位,但DIF也在低位(DIF={dif:.3f}),暂无背离"
elif dif < 0:
desc = f"DIF在零下({dif:.3f}),需等待价格下探至20日新低({price_min_20:.2f})附近"
else:
desc = f"DIF={dif:.3f}在零上,价格距20日低点{price_min_20:.2f}较远"
status.append({
'type': 'daily_bottom_divergence', 'name': '日线底背离', 'strength': 80,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_divergence(close, price_min_20, dif, dif_min_20)
})
# 3. 龙抬头
oversold = prev_sk < 20 or sk < 30
sk_cross = prev_sk <= prev_sd and sk > sd
# 稳定性检查:与检测函数一致,最近3根K值标准差 < 15
sk_arr = df['skdj_k'].values.astype(np.float64)
stable = True
if len(sk_arr) >= 3:
# ddof=1 与 pandas Series.std() 保持一致
stable = float(np.std(sk_arr[-3:], ddof=1)) < 15
triggered = bool(oversold and sk_cross and stable)
if triggered:
desc = f"✅ 已触发!SKDJ超跌金叉 K={sk:.1f}, D={sd:.1f}"
elif sk < 20:
desc = f"K={sk:.1f}在超卖区(<20),等待K上穿DK-D={sk-sd:.1f}"
elif sk < 30:
desc = f"K={sk:.1f}接近超卖区(<20),继续下探可能触发"
elif sk < 50:
desc = f"K={sk:.1f}在中位,距超卖区(K<20)还有较大距离"
else:
desc = f"K={sk:.1f}偏高,远离超卖区,不满足条件"
status.append({
'type': 'dragon_head', 'name': '龙抬头', 'strength': 75,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_dragon(sk, sd, prev_sk, prev_sd)
})
# 4. 真龙
cond_price = close > ma20
cond_ma = ma5 > ma20
cond_macd = macd_val > 0 and float(prev.get('macd', 0)) <= 0
vol_arr = df['volume'].values.astype(np.float64)
vol_avg = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean())
cond_vol = float(vol_arr[-1]) > vol_avg * 1.2 if vol_avg > 0 else False
met = sum([cond_price, cond_ma, cond_macd, cond_vol])
triggered = bool(met >= 3 and cond_price)
parts = []
if cond_price:
parts.append(f"价格>{ma20:.2f}(MA20)✓")
else:
parts.append(f"价格{close:.2f}<{ma20:.2f}(MA20)✗")
if cond_ma:
parts.append("MA5>MA20✓")
else:
parts.append(f"MA5({ma5:.2f})<MA20({ma20:.2f})✗")
if cond_macd:
parts.append("MACD翻红✓")
else:
parts.append(f"MACD={macd_val:.3f}")
if cond_vol:
parts.append("放量✓")
else:
parts.append("未放量✗")
if triggered:
desc = f"✅ 已触发!{met}/4条件满足: {', '.join(parts)}"
else:
desc = f"{met}/4条件(需≥3: {', '.join(parts)}"
status.append({
'type': 'true_dragon', 'name': '真龙', 'strength': 70,
'triggered': triggered, 'description': desc,
'readiness': min(100, met * 25) if cond_price else min(50, met * 15)
})
# 5. 短底背离
w10_start = max(0, n - 11)
window_10 = close_arr[w10_start:]
price_min_10 = float(window_10.min())
dif_w10 = dif_arr[w10_start:n - 1] if n > w10_start + 1 else dif_arr[:max(0, n - 1)]
dif_min_10 = float(dif_w10.min()) if len(dif_w10) > 0 else 0.0
at_low_10 = close <= price_min_10 * 1.01
is_actual_min_10 = bool(np.argmin(window_10) == len(window_10) - 1)
dif_div_10 = dif > dif_min_10
triggered = bool(at_low_10 and is_actual_min_10 and dif_div_10)
if triggered:
desc = f"✅ 已触发!价格接近10日新低,DIF({dif:.3f})高于前低({dif_min_10:.3f})"
elif at_low_10 and not is_actual_min_10:
desc = f"价格接近10日低位({price_min_10:.2f}),但非当前最低点"
elif at_low_10:
desc = f"价格在10日低位,但DIF也在低位,暂无背离"
else:
desc = f"价格距10日低点{price_min_10:.2f}尚远,等待回调"
status.append({
'type': 'short_bottom_divergence', 'name': '短底背离', 'strength': 65,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_divergence(close, price_min_10, dif, dif_min_10)
})
# 6. 老鼠仓
if open_p > 0:
drop = (low - open_p) / open_p * 100
recovery = (close - low) / (high - low) * 100 if high != low else 50
close_vs_open = (close - open_p) / open_p * 100
vol_avg_10 = float(vol_arr[max(0, n - 11):n - 1].mean()) if n > 10 else float(vol_arr.mean())
vol_up = float(vol_arr[-1]) > vol_avg_10 * 1.3 if vol_avg_10 > 0 else False
triggered = bool(drop < -3 and recovery > 60 and close_vs_open > -1 and vol_up)
if triggered:
desc = f"✅ 已触发!盘中跌{drop:.1f}%后回收{recovery:.0f}%,放量吸筹"
else:
parts = []
if drop >= -3:
parts.append(f"盘中最大跌幅{drop:.1f}%(需<-3%)")
else:
parts.append(f"盘中跌{drop:.1f}%✓")
if recovery <= 60:
parts.append(f"回收{recovery:.0f}%(需>60%)")
else:
parts.append(f"回收{recovery:.0f}%✓")
if not vol_up:
parts.append("未放量")
desc = f"{', '.join(parts)}"
else:
triggered = False
desc = "数据异常"
status.append({
'type': 'rat_trading', 'name': '老鼠仓', 'strength': 60,
'triggered': triggered, 'description': desc,
'readiness': 0
})
# 7. 反弹
cross = prev_ema3 <= prev_ema21 and ema3 > ema21
triggered = bool(cross)
gap = ema3 - ema21
gap_pct = gap / ema21 * 100 if ema21 > 0 else 0
if triggered:
desc = f"✅ 已触发!EMA3({ema3:.2f})上穿EMA21({ema21:.2f})"
elif ema3 < ema21:
desc = f"EMA3({ema3:.2f})<EMA21({ema21:.2f}),差{abs(gap_pct):.2f}%,等待上穿"
else:
desc = f"EMA3({ema3:.2f})>EMA21({ema21:.2f}),已在上方但非刚穿越"
status.append({
'type': 'rebound', 'name': '反弹', 'strength': 55,
'triggered': triggered, 'description': desc,
'readiness': _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21)
})
return status
def _calc_readiness(dif, dea, signal_type):
if dif > 0 and dea > 0 and dif > dea:
return 100
elif dif > 0 and dea > 0:
return 70
elif dif > dea:
return 40
else:
return max(0, int(20 + dif * 100))
def _calc_readiness_divergence(close, price_min, dif, dif_min):
price_near = close <= price_min * 1.03
dif_higher = dif > dif_min
if price_near and dif_higher:
return 90
elif price_near:
return 50
elif dif_higher and dif < 0:
return 30
return 10
def _calc_readiness_dragon(sk, sd, prev_sk, prev_sd):
if sk < 20 and sk > sd and prev_sk <= prev_sd:
return 100
elif sk < 20:
return 70
elif sk < 30:
return 40
elif sk < 50:
return 20
return 5
def _calc_readiness_rebound(ema3, ema21, prev_ema3, prev_ema21):
if prev_ema3 <= prev_ema21 and ema3 > ema21:
return 100
gap_pct = (ema3 - ema21) / ema21 * 100 if ema21 > 0 else 0
if gap_pct > 0:
return 60
elif gap_pct > -1:
return 40
elif gap_pct > -3:
return 20
return 5
File diff suppressed because it is too large Load Diff
+907
View File
@@ -0,0 +1,907 @@
"""
统一算法模块 — 全部核心算法的唯一定义处(Single Source of Truth
包含:
1. compute_recommend — 统一推荐逻辑(买入/卖出/加仓/观望等,严格遵循suanfa.md)
2. get_kline_data — 获取K线数据并返回 DataFrame(优先本地DB → 阿里云 → 腾讯 → 麦蕊 → AKShare)
3. fetch_kline_rows — 获取K线数据并返回 tuple 行列表(用于写入DB同步)
4. get_latest_price — 获取股票最新价格
5. code_to_market — 股票代码→市场判断(SH/SZ/BJ)
6. 各 API session 管理
7. compute_bull_stage — 牛股阶段识别(底部→起爆→确立→加速→补涨)
8. find_bull_stocks — 从扫描结果中找出潜在牛股
调用方:
- routes/analysis.py → compute_recommend, get_kline_data
- services/scheduler.py → compute_recommend, get_latest_price
- full_signal_scan.py → get_kline_data, API sessions
- sync_kline.py → fetch_kline_rows, API sessions
- routes/market.py → get_kline_data
"""
import threading
from datetime import datetime, timedelta
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from config import Config
# ═══════════════════════════════════════════════
# 1. 股票代码 → 市场 工具函数
# ═══════════════════════════════════════════════
def code_to_market(code):
"""6位股票代码 → 市场代码(SH/SZ/BJ)"""
if code.startswith(('0', '3')):
return 'SZ'
elif code.startswith(('8', '9')):
return 'BJ'
else:
return 'SH'
def is_bj_stock(code):
"""是否是北交所股票"""
return code.startswith(('8', '9'))
def code_to_ali_symbol(code):
"""6位股票代码 → 阿里云API格式(SH600519 / SZ000001 / BJ920720"""
return f'{code_to_market(code)}{code}'
def code_to_tencent_symbol(code):
"""6位股票代码 → 腾讯API格式(sh600519 / sz000001 / bj920720"""
return f'{code_to_market(code).lower()}{code}'
# ═══════════════════════════════════════════════
# 2. API Session 管理(线程安全,连接池复用)
# ═══════════════════════════════════════════════
_ali_session = None
_ali_lock = threading.Lock()
_tencent_session = None
_tencent_lock = threading.Lock()
def get_ali_session():
"""获取阿里云API专用 Session(线程安全,单例)"""
global _ali_session
if _ali_session is None:
with _ali_lock:
if _ali_session is None:
s = requests.Session()
retry = Retry(total=2, backoff_factor=0.3,
status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry,
pool_connections=20, pool_maxsize=20)
s.mount('https://', adapter)
s.headers.update({
'Authorization': f'APPCODE {Config.ALICLOUD_APPCODE}',
'Content-Type': 'application/x-www-form-urlencoded',
})
_ali_session = s
return _ali_session
def get_tencent_session():
"""获取腾讯K线API专用 Session(线程安全,单例)"""
global _tencent_session
if _tencent_session is None:
with _tencent_lock:
if _tencent_session is None:
s = requests.Session()
retry = Retry(total=2, backoff_factor=0.3,
status_forcelist=[500, 502, 503, 504])
adapter = HTTPAdapter(max_retries=retry,
pool_connections=20, pool_maxsize=20)
s.mount('https://', adapter)
s.headers.update({
'User-Agent': ('Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36'),
})
_tencent_session = s
return _tencent_session
# API URL 常量
ALICLOUD_KLINE_URL = Config.ALICLOUD_KLINE_URL
TENCENT_KLINE_URL = 'https://proxy.finance.qq.com/ifzqgtimg/appstock/app/newfqkline/get'
# ═══════════════════════════════════════════════
# 3. K线数据获取 — DataFrame 格式(供信号检测/分析用)
# ═══════════════════════════════════════════════
def get_kline_data(stock_code, days=120, use_local_db=True):
"""
获取K线数据,返回 pandas DataFrame (columns: date, open, high, low, close, volume)
数据源优先级: 本地DB → 阿里云API → 腾讯API → 麦蕊API → AKShare
参数:
stock_code: 6位股票代码
days: 获取天数
use_local_db: 是否优先使用本地DB(全景扫描时为True, 实时分析时可为False)
返回:
DataFrame 或 None
"""
import pandas as pd
# 1. 优先从本地数据库读取
if use_local_db:
df = _get_kline_from_local_db(stock_code, days)
if df is not None:
return df
# 2. 阿里云K线API(沪深最稳定,北交所可能不支持)
if not is_bj_stock(stock_code):
df = _fetch_ali_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
# 3. 腾讯K线API(全市场,含北交所)
df = _fetch_tencent_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
# 4. 麦蕊API
df = _fetch_mairui_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
# 5. AKShare
df = _fetch_akshare_kline_df(stock_code, days)
if df is not None and len(df) >= 30:
return df
return None
def _get_kline_from_local_db(stock_code, days=120):
"""从本地数据库读取K线(最快,毫秒级)"""
import pandas as pd
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
conn.autocommit = True
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
conn.close()
if rows and len(rows) >= 30:
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
df['date'] = df['date'].astype(str)
for col in ('open', 'high', 'low', 'close', 'volume'):
df[col] = df[col].astype(float)
return df
except Exception:
pass
return None
# ---- 线程本地连接(供多线程扫描时使用,避免频繁建连) ----
_thread_local = threading.local()
def get_kline_from_local_db_threaded(stock_code, days=120):
"""多线程扫描专用:使用线程本地连接从本地DB读取K线"""
import pandas as pd
try:
conn = getattr(_thread_local, 'kline_conn', None)
if conn is None or conn.closed:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
conn.autocommit = True
_thread_local.kline_conn = conn
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
with conn.cursor() as cur:
cur.execute("""
SELECT trade_date, open, high, low, close, volume
FROM stock_kline_daily
WHERE code = %s AND trade_date >= %s
ORDER BY trade_date
""", (stock_code, start_date))
rows = cur.fetchall()
if rows and len(rows) >= 30:
df = pd.DataFrame(rows, columns=['date', 'open', 'high', 'low', 'close', 'volume'])
df['date'] = df['date'].astype(str)
for col in ('open', 'high', 'low', 'close', 'volume'):
df[col] = df[col].astype(float)
return df
except Exception:
pass
return None
def _fetch_ali_kline_df(stock_code, days=120):
"""阿里云K线API → DataFrame"""
import pandas as pd
try:
session = get_ali_session()
symbol = code_to_ali_symbol(stock_code)
resp = session.post(ALICLOUD_KLINE_URL, data={
'symbol': symbol,
'type': '240',
'limit': str(min(days, 300)),
'ma': '5',
}, timeout=10)
if resp.status_code == 200:
data = resp.json()
if data.get('success') and data.get('data', {}).get('list'):
records = []
for item in data['data']['list']:
day_str = item.get('day', '')
if not day_str or len(day_str) < 10:
continue
records.append({
'date': day_str[:10],
'open': float(item.get('open', 0)),
'high': float(item.get('high', 0)),
'low': float(item.get('low', 0)),
'close': float(item.get('close', 0)),
'volume': float(item.get('volume', 0)),
})
if records:
return pd.DataFrame(records)
except Exception:
pass
return None
def _fetch_tencent_kline_df(stock_code, days=120):
"""腾讯K线API → DataFrame(全市场含北交所)"""
import pandas as pd
try:
session = get_tencent_session()
symbol = code_to_tencent_symbol(stock_code)
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
resp = session.get(TENCENT_KLINE_URL, params={
'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq',
}, timeout=15)
if resp.status_code == 200:
data = resp.json()
stock_data = data.get('data', {}).get(symbol, {})
klines = stock_data.get('qfqday') or stock_data.get('day') or []
if klines:
records = []
for item in klines:
if len(item) < 6:
continue
# 腾讯格式: [date, open, close, high, low, volume, ...]
records.append({
'date': item[0][:10],
'open': float(item[1]),
'high': float(item[3]), # high = position 3
'low': float(item[4]), # low = position 4
'close': float(item[2]), # close = position 2
'volume': float(item[5]),
})
if records:
return pd.DataFrame(records)
except Exception:
pass
return None
def _fetch_mairui_kline_df(stock_code, days=120):
"""麦蕊API → DataFrame"""
import pandas as pd
try:
from services.mairui_api import get_kline
result = get_kline(stock_code, period='d', days=days, adjust='f')
if result['success'] and result['data']:
df = pd.DataFrame(result['data'])
df.rename(columns={
'date': 'date', 'open': 'open', 'high': 'high',
'low': 'low', 'close': 'close', 'volume': 'volume',
}, inplace=True)
if len(df) >= 30:
return df
except Exception:
pass
return None
def _fetch_akshare_kline_df(stock_code, days=120):
"""AKShare → DataFrame (支持自动降级到腾讯数据源)"""
import pandas as pd
try:
from utils.data_fetcher import fetch_stock_hist
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = fetch_stock_hist(
stock_code=stock_code, period='daily',
start_date=start_date, end_date=end_date, adjust='qfq',
)
if df is not None and not df.empty:
df = df.rename(columns={
'日期': 'date', '开盘': 'open', '最高': 'high',
'最低': 'low', '收盘': 'close', '成交量': 'volume',
})
df = df[['date', 'open', 'high', 'low', 'close', 'volume']]
return df
except Exception:
pass
return None
# ═══════════════════════════════════════════════
# 4. K线数据获取 — tuple行格式(供 sync_kline.py 写入DB用)
# ═══════════════════════════════════════════════
def fetch_kline_rows(code, days):
"""
获取K线数据,返回 list of tuple: (code, date, open, high, low, close, volume, amount)
数据源优先级: 阿里云API → 腾讯API → 麦蕊API → AKShare
北交所(8XX/9XX)直接走腾讯API
供 sync_kline.py 同步到本地数据库使用。
"""
bj = is_bj_stock(code)
# 北交所:直接用腾讯API(阿里云/Mairui不支持BJ
if bj:
rows = _fetch_tencent_kline_rows(code, days)
if rows:
return rows
return None
# 1. 首选:阿里云K线API
rows = _fetch_ali_kline_rows(code, days)
if rows:
return rows
# 2. 回退:腾讯API
rows = _fetch_tencent_kline_rows(code, days)
if rows:
return rows
# 3. 回退:Mairui API
rows = _fetch_mairui_kline_rows(code, days)
if rows:
return rows
# 4. 最后回退:AKShare
rows = _fetch_akshare_kline_rows(code, days)
if rows:
return rows
return None
def _fetch_ali_kline_rows(code, days):
"""阿里云K线API → tuple rows"""
try:
session = get_ali_session()
symbol = code_to_ali_symbol(code)
resp = session.post(ALICLOUD_KLINE_URL, data={
'symbol': symbol,
'type': '240',
'limit': str(min(days, 300)),
'ma': '5',
}, timeout=10)
if resp.status_code == 200:
data = resp.json()
if data.get('success') and data.get('data', {}).get('list'):
rows = []
for item in data['data']['list']:
day_str = item.get('day', '')
if not day_str or len(day_str) < 10:
continue
rows.append((
code,
day_str[:10],
float(item.get('open', 0)),
float(item.get('high', 0)),
float(item.get('low', 0)),
float(item.get('close', 0)),
int(item.get('volume', 0)),
float(item.get('amount', 0)),
))
if rows:
return rows
except Exception:
pass
return None
def _fetch_tencent_kline_rows(code, days):
"""腾讯K线API → tuple rows(全市场含北交所)"""
try:
symbol = code_to_tencent_symbol(code)
session = get_tencent_session()
start_date = (datetime.now() - timedelta(days=days + 30)).strftime('%Y-%m-%d')
resp = session.get(TENCENT_KLINE_URL, params={
'param': f'{symbol},day,{start_date},,{min(days, 300)},qfq',
}, timeout=15)
if resp.status_code == 200:
data = resp.json()
stock_data = data.get('data', {}).get(symbol, {})
klines = stock_data.get('qfqday') or stock_data.get('day') or []
if klines:
rows = []
for item in klines:
if len(item) < 6:
continue
date_str = item[0]
if not date_str or len(date_str) < 10:
continue
rows.append((
code,
date_str[:10],
float(item[1]), # open
float(item[3]), # high (position 3)
float(item[4]), # low (position 4)
float(item[2]), # close (position 2)
int(float(item[5])), # volume
float(item[8]) * 10000 if len(item) > 8 and item[8] else 0,
))
if rows:
return rows
except Exception:
pass
return None
def _fetch_mairui_kline_rows(code, days):
"""麦蕊API → tuple rows"""
try:
from services.mairui_api import get_kline
result = get_kline(code, period='d', days=days, adjust='f')
if result['success'] and result['data']:
rows = []
for item in result['data']:
date_str = item.get('date', '')
if not date_str:
continue
rows.append((
code,
date_str,
item.get('open', 0),
item.get('high', 0),
item.get('low', 0),
item.get('close', 0),
int(item.get('volume', 0)),
item.get('amount', 0),
))
if rows:
return rows
except Exception:
pass
return None
def _fetch_akshare_kline_rows(code, days):
"""AKShare → tuple rows (支持自动降级到腾讯数据源)"""
try:
from utils.data_fetcher import fetch_stock_hist
end_date = datetime.now().strftime('%Y%m%d')
start_date = (datetime.now() - timedelta(days=days)).strftime('%Y%m%d')
df = fetch_stock_hist(
stock_code=code, period='daily',
start_date=start_date, end_date=end_date, adjust='qfq',
)
if df is not None and not df.empty:
rows = []
for _, r in df.iterrows():
rows.append((
code,
str(r['日期']),
float(r['开盘']),
float(r['最高']),
float(r['最低']),
float(r['收盘']),
int(r['成交量']),
float(r.get('成交额', 0)),
))
if rows:
return rows
except Exception:
pass
return None
# ═══════════════════════════════════════════════
# 5. 统一推荐算法
# ═══════════════════════════════════════════════
def compute_recommend(signal_status, indicators, triggered_count, is_holding):
"""
统一推荐逻辑 — 全局唯一定义(严格遵循 suanfa.md 体系最强战法)。
体系最强战法流程(suanfa.md):
1. 日线底背离 → 纳入关注范围
2. 龙抬头出现 → 执行买入操作(实操核心买点)
3. 真龙/主升浪 → 持有仓位+加仓(不是新买入!)
4. 不见主升浪 → 不出场
参数:
signal_status: list[dict] 信号状态列表(来自 signal_detector._check_all_signal_status
indicators: dict 最新技术指标(含 macd.dif, macd.dea 等)
triggered_count: int 触发信号数量
is_holding: bool 当前是否持仓该股票
返回:
tuple: (signal_type, display_text, reason, recommend_rate)
- signal_type: 'buy' | 'sell' | 'watch'
- display_text: '买入' | '卖出' | '加仓' | '持有' | '关注' | '观察' | '观望'
- reason: str 推荐理由
- recommend_rate: int 推荐评分 0-100
使用场景:
- 全景扫描结果推荐列
- 策略建议分档
- 提醒 tab 买卖推荐
- 模拟交易自动买卖决策
"""
if not signal_status:
return ('watch', '观望', '暂无信号数据', 0)
ss = signal_status
sig_map = {}
for s in ss:
sig_map[s.get('type', '')] = s
has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False)
has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False)
has_dragon = sig_map.get('dragon_head', {}).get('triggered', False)
has_real_dragon = sig_map.get('true_dragon', {}).get('triggered', False)
macd = (indicators or {}).get('macd', {})
dif = macd.get('dif', 0)
dea = macd.get('dea', 0)
triggered_signals = [s.get('name', s.get('type', '')) for s in ss if s.get('triggered')]
# ════════════════════════════════════════════
# 持仓逻辑(suanfa.md 步骤3-4
# ════════════════════════════════════════════
if is_holding:
# 卖出条件: MACD死叉 + 无主升浪 → 趋势走弱,不见主升浪则出场
if dif < dea and not has_main_wave:
return ('sell', '卖出',
f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})+主升浪消失", 75)
# 主升浪 → 加仓(suanfa.md: 主升浪=持有仓位+加仓)
if has_main_wave:
return ('buy', '加仓', '主升浪信号 → 加速拉升阶段,建议加仓', 90)
# 真龙 → 持有(suanfa.md: 真龙=趋势确认,持有)
if has_real_dragon:
return ('buy', '持有', '真龙信号 → 趋势确立,继续持有', 70)
return ('watch', '观望', '持仓中,等待主升浪信号', 50)
# ════════════════════════════════════════════
# 非持仓逻辑(suanfa.md 步骤1-2
# ════════════════════════════════════════════
# 最佳买入: 底背离+龙抬头(suanfa.md 步骤1→2 的理想组合)
# MACD 死叉时降级,避免与持仓卖出信号矛盾
if has_divergence and has_dragon:
macd_golden = (dif is None or dea is None or dif >= dea)
if macd_golden:
return ('buy', '买入', '日线底背离+龙抬头 → 最佳买入信号', 95)
return ('watch', '关注',
f'底背离+龙抬头但MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}) → 等待MACD金叉确认', 65)
# 核心买入: 龙抬头(suanfa.md: "龙抬头出现 → 执行买入操作")
# 但需要 MACD 配合:如果 MACD 死叉则信号冲突,降级为关注
if has_dragon:
macd_ok = (dif is None or dea is None or dif >= dea) # MACD 金叉或无数据
if has_main_wave and macd_ok:
return ('buy', '买入', '龙抬头+主升浪 → 强势买入信号', 90)
if macd_ok:
return ('buy', '买入', '龙抬头出现 → 短线起爆点,执行买入', 80)
# MACD死叉 + 龙抬头 → 信号冲突,降级为关注
return ('watch', '关注',
f'龙抬头出现但MACD死叉(DIF={dif:.3f}<DEA={dea:.3f}) → 信号冲突,谨慎观望', 55)
# 主升浪(非持仓)→ 不是新买入!suanfa.md: 主升浪=加速段,属于持有/加仓信号
if has_main_wave:
return ('watch', '关注', '主升浪(加速段) → 已过最佳买点,关注回调机会', 75)
# 真龙 → 关注(趋势刚启动,可跟踪)
if has_real_dragon:
return ('watch', '关注', '真龙出现 → 趋势启动,等待龙抬头确认', 65)
# MACD死叉 → 卖出/回避
if dif is not None and dea is not None and dif < dea:
return ('sell', '卖出', f"MACD死叉(DIF={dif:.3f}<DEA={dea:.3f})", 75)
# 底背离 → 关注(suanfa.md 步骤1: 纳入关注范围)
if has_divergence:
return ('watch', '关注', '日线底背离出现 → 纳入关注,等待龙抬头', 60)
# 其他信号 → 观察
if triggered_count and triggered_count > 0:
sigs = ''.join(triggered_signals[:3])
return ('watch', '观察', f"触发{triggered_count}个信号: {sigs}", 40)
return ('watch', '观望', '无核心信号触发', 0)
# ═══════════════════════════════════════════════
# 6. 获取最新价格
# ═══════════════════════════════════════════════
def get_latest_price(stock_code):
"""
获取股票最新价格(从本地 stock_realtime_price 表)
返回:
float: 最新价格, 失败返回 0
"""
try:
import psycopg2
conn = psycopg2.connect(
host=Config.DB_HOST, port=Config.DB_PORT,
dbname=Config.DB_NAME, user=Config.DB_USER, password=Config.DB_PASSWORD,
)
cur = conn.cursor()
cur.execute("""
SELECT price FROM stock_realtime_price
WHERE code = %s AND price > 0
""", (stock_code,))
row = cur.fetchone()
conn.close()
if row:
return float(row[0])
except Exception:
pass
return 0
# ═══════════════════════════════════════════════
# 7. 牛股阶段识别(suanfa.md 标准牛股启动信号先后顺序)
# ═══════════════════════════════════════════════
# 标准牛股启动流程(底部→拉升):
# 阶段1 → 日线底背离/短底背离(跌到底部,停止下跌)
# 阶段2 → 龙抬头(资金进场,短线起爆)
# 阶段3 → 真龙(趋势正式确立)
# 阶段4 → ★主升浪(进入加速段,利润兑现最快)
# 阶段5 → 反弹(中途回调后的补涨信号)
# 补充 → 老鼠仓可在底部任意位置提前出现
BULL_STAGES = {
1: {'name': '底部探测', 'icon': '', 'color': '#2196F3',
'desc': '日线底背离/短底背离 → 跌到底部,停止下跌'},
2: {'name': '资金进场', 'icon': '', 'color': '#4CAF50',
'desc': '龙抬头 → 资金进场,短线起爆点(最佳买入时机)'},
3: {'name': '趋势确立', 'icon': '', 'color': '#FF9800',
'desc': '真龙 → 中期趋势正式确立'},
4: {'name': '加速拉升', 'icon': '', 'color': '#F44336',
'desc': '★主升浪 → 进入加速段,利润兑现最快'},
5: {'name': '回调补涨', 'icon': '', 'color': '#9C27B0',
'desc': '反弹 → 中途回调后的补涨信号'},
}
def compute_bull_stage(signal_status):
"""
识别股票在标准牛股启动流程中的阶段。
参数:
signal_status: list[dict] 信号状态列表
返回:
dict: {
'stage': int (0-5, 0=未进入流程),
'stage_name': str,
'stage_icon': str,
'stage_color': str,
'stage_desc': str,
'signals_active': list[str], # 当前活跃的信号名称
'progress': int (0-100), # 牛股流程进度百分比
'next_signal': str, # 下一个期待的信号
'investment_advice': str, # 投资建议
'has_rat_trading': bool, # 是否有老鼠仓(提前埋伏信号)
}
"""
if not signal_status:
return {
'stage': 0, 'stage_name': '观望', 'stage_icon': '',
'stage_color': '#9E9E9E', 'stage_desc': '无信号触发',
'signals_active': [], 'progress': 0,
'next_signal': '等待底背离/短底背离', 'investment_advice': '暂无操作机会',
'has_rat_trading': False,
}
sig_map = {}
for s in signal_status:
sig_map[s.get('type', '')] = s
has_divergence = sig_map.get('daily_bottom_divergence', {}).get('triggered', False)
has_short_div = sig_map.get('short_bottom_divergence', {}).get('triggered', False)
has_dragon = sig_map.get('dragon_head', {}).get('triggered', False)
has_true_dragon = sig_map.get('true_dragon', {}).get('triggered', False)
has_main_wave = sig_map.get('main_rising_wave', {}).get('triggered', False)
has_rebound = sig_map.get('rebound', {}).get('triggered', False)
has_rat = sig_map.get('rat_trading', {}).get('triggered', False)
signals_active = []
if has_divergence:
signals_active.append('日线底背离')
if has_short_div:
signals_active.append('短底背离')
if has_dragon:
signals_active.append('龙抬头')
if has_true_dragon:
signals_active.append('真龙')
if has_main_wave:
signals_active.append('主升浪')
if has_rebound:
signals_active.append('反弹')
if has_rat:
signals_active.append('老鼠仓')
# 确定阶段(按最高阶段判定)
stage = 0
if has_main_wave:
stage = 4
elif has_true_dragon:
stage = 3
elif has_dragon:
stage = 2
elif has_divergence or has_short_div:
stage = 1
elif has_rebound:
stage = 5
elif has_rat:
stage = 1 # 老鼠仓归入底部阶段
if stage == 0:
return {
'stage': 0, 'stage_name': '观望', 'stage_icon': '',
'stage_color': '#9E9E9E', 'stage_desc': '无核心信号触发',
'signals_active': signals_active, 'progress': 0,
'next_signal': '等待底背离/短底背离',
'investment_advice': '暂无操作机会',
'has_rat_trading': has_rat,
}
info = BULL_STAGES[stage]
# 计算流程进度(越靠后越高)
# 加分项:多信号叠加说明流程更完整
base_progress = {1: 20, 2: 45, 3: 65, 4: 85, 5: 50}
progress = base_progress.get(stage, 0)
if stage <= 2 and has_divergence:
progress += 10 # 有底背离做基础更好
if stage >= 2 and has_dragon:
progress += 5
if stage >= 3 and has_true_dragon:
progress += 5
if has_rat:
progress += 5 # 老鼠仓加分
progress = min(progress, 100)
# 下一步信号期待
next_signals = {
1: '等待龙抬头(资金进场信号)',
2: '等待真龙(趋势确认信号)',
3: '等待主升浪(加速拉升信号)',
4: '持有!不见主升浪消失不出场',
5: '等待龙抬头/真龙确认趋势',
}
# 投资建议
advices = {
1: '纳入关注池,等待龙抬头出现后买入',
2: '最佳买入时机!龙抬头=实操核心买点',
3: '趋势已确立,可以追入,建议等回调买入',
4: '已在加速段,持仓者加仓/持有,新入者谨慎追高',
5: '回调中可关注,但需确认不是假反弹',
}
return {
'stage': stage,
'stage_name': info['name'],
'stage_icon': info['icon'],
'stage_color': info['color'],
'stage_desc': info['desc'],
'signals_active': signals_active,
'progress': progress,
'next_signal': next_signals.get(stage, ''),
'investment_advice': advices.get(stage, ''),
'has_rat_trading': has_rat,
}
def find_bull_stocks(scan_rows, holding_codes=None):
"""
从扫描结果中找出潜在牛股,按阶段分组排序。
参数:
scan_rows: list[dict] 扫描结果列表 (含 code, name, signal_status, indicators, triggered_count)
holding_codes: set 持仓代码集合
返回:
dict: {
'stages': {1: [...], 2: [...], ...}, # 按阶段分组的股票列表
'summary': {1: count, 2: count, ...}, # 各阶段数量统计
'total': int, # 有信号的总数
}
"""
if holding_codes is None:
holding_codes = set()
stages = {1: [], 2: [], 3: [], 4: [], 5: []}
summary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0}
for row in scan_rows:
signal_status = row.get('signal_status') or []
if not signal_status:
summary[0] += 1
continue
# 判断牛股阶段
bull = compute_bull_stage(signal_status)
stage = bull['stage']
summary[stage] += 1
if stage == 0:
continue
# 计算推荐
is_holding = row.get('code', '') in holding_codes
st, disp, reason, rate = compute_recommend(
signal_status, row.get('indicators'),
row.get('triggered_count'), is_holding,
)
item = {
'code': row.get('code', ''),
'name': row.get('name', ''),
'stage': stage,
'stage_name': bull['stage_name'],
'stage_icon': bull['stage_icon'],
'stage_color': bull['stage_color'],
'signals_active': bull['signals_active'],
'progress': bull['progress'],
'next_signal': bull['next_signal'],
'investment_advice': bull['investment_advice'],
'has_rat_trading': bull['has_rat_trading'],
'recommend_type': st,
'recommend_text': disp,
'recommend_reason': reason,
'recommend_rate': rate,
'is_holding': is_holding,
'triggered_count': row.get('triggered_count', 0),
}
stages[stage].append(item)
# 每个阶段内按推荐评分降序排序
for stage_num in stages:
stages[stage_num].sort(key=lambda x: (-x['recommend_rate'], -x['progress']))
total = sum(len(v) for v in stages.values())
return {
'stages': stages,
'summary': summary,
'total': total,
'stage_info': BULL_STAGES,
}
+357
View File
@@ -0,0 +1,357 @@
"""
股票数据服务 - 获取、缓存、分析
"""
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import traceback
import json
import os
from config import Config
# ========== 股票名称缓存 ==========
_stock_name_cache = {}
def _load_stock_name_cache():
"""从本地文件加载股票名称缓存"""
global _stock_name_cache
try:
if os.path.exists(Config.STOCK_NAME_CACHE_FILE):
with open(Config.STOCK_NAME_CACHE_FILE, 'r', encoding='utf-8') as f:
_stock_name_cache = json.load(f)
print(f"加载股票名称缓存:{len(_stock_name_cache)}")
except Exception as e:
print(f"加载股票名称缓存失败: {e}")
def _save_stock_name_cache():
"""保存股票名称缓存到本地"""
try:
with open(Config.STOCK_NAME_CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(_stock_name_cache, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存股票名称缓存失败: {e}")
def get_stock_name(stock_code):
"""获取股票名称 — 使用腾讯财经API"""
global _stock_name_cache
if stock_code in _stock_name_cache:
return _stock_name_cache[stock_code]
# 腾讯财经API获取股票名称
try:
import requests as _req
tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
_r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5,
headers={'Referer': 'https://finance.qq.com'})
if _r.status_code == 200 and '\"' in _r.text:
_fields = _r.text.split('\"')[1].split('~')
if len(_fields) > 2 and _fields[1]:
_stock_name_cache[stock_code] = _fields[1]
_save_stock_name_cache()
return _fields[1]
except Exception as e:
print(f"获取股票名称失败(腾讯): {e}")
return None
# ========== 股票数据缓存 ==========
def _get_cache_file_path(stock_code):
"""获取缓存文件路径"""
return os.path.join(Config.STOCK_DATA_CACHE_DIR, f'{stock_code}.json')
def load_cached_data(stock_code):
"""加载缓存的股票数据"""
cache_file = _get_cache_file_path(stock_code)
if os.path.exists(cache_file):
try:
with open(cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
df = pd.DataFrame(data['records'])
if not df.empty and '日期' in df.columns:
df['日期'] = pd.to_datetime(df['日期'])
return df, data.get('stock_name'), data.get('last_update')
except Exception as e:
print(f"加载缓存数据失败: {e}")
return None, None, None
def save_cached_data(stock_code, df, stock_name):
"""保存股票数据到缓存"""
cache_file = _get_cache_file_path(stock_code)
try:
df_copy = df.copy()
df_copy['日期'] = df_copy['日期'].dt.strftime('%Y-%m-%d')
records = df_copy.to_dict('records')
data = {
'stock_code': stock_code,
'stock_name': stock_name,
'last_update': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'records': records
}
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"已保存 {stock_code} 数据,共 {len(records)}")
except Exception as e:
print(f"保存缓存数据失败: {e}")
# ========== 获取股票资金流向数据 ==========
def get_stock_fund_flow(stock_code, start_date, end_date, force_refresh=False):
"""
获取股票资金流向数据(支持缓存,增量获取)
force_refresh: 强制刷新缓存
返回: (DataFrame, stock_name, error_msg)
"""
try:
# 判断市场
if stock_code.startswith('6'):
market = 'sh'
elif stock_code.startswith('0') or stock_code.startswith('3'):
market = 'sz'
else:
return None, None, "无法识别股票代码所属市场"
stock_name = get_stock_name(stock_code)
start = pd.to_datetime(start_date)
end = pd.to_datetime(end_date)
# 加载缓存
cached_df, cached_name, last_update = load_cached_data(stock_code)
need_fetch = force_refresh
new_data_df = None
if cached_df is not None and not cached_df.empty:
# 如果缓存是今天的,直接使用
if last_update:
try:
update_date = pd.to_datetime(last_update.split()[0])
today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d'))
if update_date >= today and not force_refresh:
# 今天已更新,直接使用缓存
df = cached_df[(cached_df['日期'] >= start) & (cached_df['日期'] <= end)]
df = df.sort_values('日期').reset_index(drop=True)
return df, cached_name or stock_name, None
except:
pass
cached_max_date = cached_df['日期'].max()
today = pd.to_datetime(datetime.now().strftime('%Y-%m-%d'))
# 如果缓存数据不超过2天,直接使用(优化分析速度)
if cached_max_date >= today - timedelta(days=2) and not force_refresh:
if cached_df['日期'].min() <= start:
need_fetch = False
new_data_df = cached_df
print(f"使用缓存数据: {stock_code}, 最新日期: {cached_max_date.strftime('%Y-%m-%d')}")
if stock_name is None and cached_name:
stock_name = cached_name
if need_fetch:
# 东方财富资金流向API已不可用(腾讯云网络限制),使用缓存数据
print(f"资金流向API不可用,使用缓存: {stock_code}")
if cached_df is not None:
new_data_df = cached_df
else:
return None, None, "资金流向API不可用(东方财富已封锁),且无缓存数据"
if new_data_df is None or new_data_df.empty:
# 最后尝试使用缓存数据(即使不在日期范围内)
if cached_df is not None and not cached_df.empty:
print(f"使用全部缓存数据: {stock_code}")
df = cached_df.sort_values('日期').reset_index(drop=True)
return df, cached_name or stock_name, None
return None, None, "无法获取数据"
# 筛选日期范围
df = new_data_df[(new_data_df['日期'] >= start) & (new_data_df['日期'] <= end)]
# 如果筛选后为空,使用全部数据
if df.empty and not new_data_df.empty:
print(f"日期范围无数据,使用全部缓存: {stock_code}")
df = new_data_df
df = df.sort_values('日期').reset_index(drop=True)
return df, stock_name, None
except Exception as e:
traceback.print_exc()
return None, None, f"获取数据失败: {str(e)}"
# ========== 分析股票数据 ==========
def analyze_fund_flow_impact(df):
"""分析资金流向对股价的影响(含成交量分析)"""
if df is None or df.empty:
return None
df = df.sort_values('日期').reset_index(drop=True)
threshold = 2.0
if '超大单净流入-净占比' not in df.columns:
return None
df['超大单净流入-净占比'] = df['超大单净流入-净占比'].fillna(0)
df['主力净流入-净占比'] = df['主力净流入-净占比'].fillna(0)
df['超大单流向'] = df['超大单净流入-净占比'].apply(
lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通')
)
df['主力流向'] = df['主力净流入-净占比'].apply(
lambda x: '大额流入' if x >= threshold else ('大额流出' if x <= -threshold else '普通')
)
# 计算价格位置(改为60日)
latest = df.iloc[-1]
lookback = 60 # 从20日改为60日
try:
actual_lookback = min(len(df), lookback)
if actual_lookback >= 5: # 至少需要5天数据
recent = df.tail(actual_lookback)
high = float(recent['收盘价'].max() or 0)
low = float(recent['收盘价'].min() or 0)
current_price = float(latest.get('收盘价') or 0)
price_position = (current_price - low) / (high - low) * 100 if high != low else 50
else:
price_position = 50
except:
price_position = 50
# 成交量分析(基于主力净流入-净额作为成交额指标)
volume_ratio = 1.0 # 默认值
volume_trend = '普通'
try:
if '主力净流入-净额' in df.columns and len(df) >= 10:
# 使用主力净流入绝对值作为活跃度指标
df['活跃度'] = df['主力净流入-净额'].abs()
recent_5 = df.tail(5)['活跃度'].mean()
recent_20 = df.tail(min(20, len(df)))['活跃度'].mean()
if recent_20 > 0:
volume_ratio = recent_5 / recent_20
if volume_ratio >= 1.5:
volume_trend = '放量'
elif volume_ratio <= 0.5:
volume_trend = '缩量'
else:
volume_trend = '正常'
except:
pass
# 计算均线MA5和MA20
ma5 = 0
ma20 = 0
try:
if '收盘价' in df.columns and len(df) >= 5:
ma5 = df.tail(5)['收盘价'].mean()
if '收盘价' in df.columns and len(df) >= 20:
ma20 = df.tail(20)['收盘价'].mean()
except:
pass
# 处理日期格式(可能是datetime或字符串)
def format_date(d):
if hasattr(d, 'strftime'):
return d.strftime('%Y-%m-%d')
return str(d)[:10] if d else ''
def safe_float(val, default=0):
try:
return float(val) if val is not None else default
except:
return default
return {
'最新数据': {
'日期': format_date(latest['日期']),
'收盘价': safe_float(latest.get('收盘价')),
'涨跌幅': safe_float(latest.get('涨跌幅')),
'价格位置': safe_float(price_position),
'超大单净流入占比': safe_float(latest.get('超大单净流入-净占比')),
'主力净流入占比': safe_float(latest.get('主力净流入-净占比')),
'超大单流向': latest.get('超大单流向', '普通'),
'主力流向': latest.get('主力流向', '普通'),
'成交量比': safe_float(volume_ratio, 1.0),
'量能趋势': volume_trend,
'MA5': safe_float(ma5),
'MA20': safe_float(ma20)
},
'数据概览': {
'总交易日数': len(df),
'计算周期': min(len(df), lookback),
'日期范围': {
'开始': format_date(df['日期'].min()),
'结束': format_date(df['日期'].max())
}
}
}
# ========== 实时价格 ==========
def get_realtime_price(stock_code):
"""获取实时价格(使用mairuiapi,更稳定)"""
try:
from services.mairui_api import get_realtime_price as mairui_get_price
result = mairui_get_price(stock_code)
if result['success']:
return result
except Exception as e:
print(f"mairuiapi获取实时价格失败({stock_code}): {e}")
# 备用方案2:使用腾讯财经API(腾讯云可用)
try:
import requests as _req
tcode = ('sh' if stock_code.startswith('6') else 'sz') + stock_code
_r = _req.get(f'http://qt.gtimg.cn/q={tcode}', timeout=5,
headers={'Referer': 'https://finance.qq.com'})
if _r.status_code == 200 and '\"' in _r.text:
_fields = _r.text.split('\"')[1].split('~')
if len(_fields) > 35 and _fields[3]:
return {
'success': True,
'data': {
'code': stock_code,
'name': _fields[1],
'price': float(_fields[3]),
'change': float(_fields[32]) if _fields[32] else 0,
}
}
except Exception as e:
print(f"腾讯财经备用方案失败({stock_code}): {e}")
return {'success': False, 'error': '获取失败'}
def get_realtime_prices_batch(stock_codes):
"""批量获取实时价格"""
try:
from services.mairui_api import get_realtime_prices_batch as mairui_batch
return mairui_batch(stock_codes)
except Exception as e:
print(f"mairuiapi批量获取失败: {e}")
return {}
# ========== 热门股票 ==========
def get_hot_stocks(limit=100):
"""获取热门股票 — 东方财富API已不可用,返回空"""
# stock_hot_rank_em 为东方财富API,已在腾讯云被封锁
return []
# 初始化时加载缓存
_load_stock_name_cache()
+130
View File
@@ -0,0 +1,130 @@
"""
技术指标计算模块(numpy向量化优化版)
实现 MACD、SKDJ、EMA 等技术指标
优化要点:
- calc_sma 使用 numpy 原生数组替代 pandas.iloc,速度提升 5-10x
- calc_all_indicators 智能跳过已是 float 的类型转换
"""
import pandas as pd
import numpy as np
def calc_ema(series, period):
"""计算指数移动平均线(EMA) — 使用pandas的C底层ewm实现,已足够快"""
return series.ewm(span=period, adjust=False).mean()
def calc_sma(series, period, weight=1):
"""
计算SMA(通达信公式风格) — numpy优化版
SMA(X, N, M) = (M * X + (N - M) * prev_SMA) / N
优化:使用 numpy 原生数组 arr[i] 替代 pandas series.iloc[i]
numpy 数组元素访问约 50nspandas iloc 约 5μs,提升 ~100x
"""
arr = series.values.astype(np.float64)
n = len(arr)
result = np.empty(n, dtype=np.float64)
result[0] = arr[0]
w = np.float64(weight)
carry = np.float64(period - weight)
inv_p = np.float64(1.0 / period)
for i in range(1, n):
result[i] = (w * arr[i] + carry * result[i - 1]) * inv_p
return pd.Series(result, index=series.index)
def calc_macd(close, fast=12, slow=26, signal=9):
"""
计算MACD指标
返回: DIF, DEA, MACD柱
"""
ema_fast = calc_ema(close, fast)
ema_slow = calc_ema(close, slow)
dif = ema_fast - ema_slow
dea = calc_ema(dif, signal)
macd_hist = 2 * (dif - dea)
return dif, dea, macd_hist
def calc_kdj(high, low, close, n=9, m1=3, m2=3):
"""
计算KDJ指标
返回: K, D, J
"""
lowest_low = low.rolling(window=n, min_periods=1).min()
highest_high = high.rolling(window=n, min_periods=1).max()
rsv = pd.Series(np.where(
highest_high == lowest_low, 50,
(close - lowest_low) / (highest_high - lowest_low) * 100
), index=close.index, dtype=float)
k = calc_sma(rsv, m1, 1)
d = calc_sma(k, m2, 1)
j = 3 * k - 2 * d
return k, d, j
def calc_skdj(high, low, close, n=9, m=3):
"""
计算SKDJ(慢速随机指标)
对RSV先做一次SMA得到K_fast,再对K_fast做两次SMA得到SKDJ的K和D
返回: K, D
"""
lowest_low = low.rolling(window=n, min_periods=1).min()
highest_high = high.rolling(window=n, min_periods=1).max()
rsv = pd.Series(np.where(
highest_high == lowest_low, 50,
(close - lowest_low) / (highest_high - lowest_low) * 100
), index=close.index, dtype=float)
k_fast = calc_sma(rsv, m, 1)
k = calc_sma(k_fast, m, 1)
d = calc_sma(k, m, 1)
return k, d
def calc_all_indicators(df):
"""
计算所有技术指标并添加到DataFrame(优化版)
df 需要包含: close, high, low, open, volume 列
返回: 添加了指标列的DataFrame
优化:智能跳过已是 float64 的列,避免重复 astype
"""
close = df['close']
high = df['high']
low = df['low']
# 智能类型转换:仅在需要时转换
if not np.issubdtype(close.dtype, np.floating):
close = close.astype(np.float64)
high = high.astype(np.float64)
low = low.astype(np.float64)
df['ema3'] = calc_ema(close, 3)
df['ema21'] = calc_ema(close, 21)
dif, dea, macd_hist = calc_macd(close)
df['dif'] = dif
df['dea'] = dea
df['macd'] = macd_hist
k, d, j = calc_kdj(high, low, close)
df['kdj_k'] = k
df['kdj_d'] = d
df['kdj_j'] = j
sk, sd = calc_skdj(high, low, close)
df['skdj_k'] = sk
df['skdj_d'] = sd
df['ma5'] = close.rolling(5).mean()
df['ma10'] = close.rolling(10).mean()
df['ma20'] = close.rolling(20).mean()
df['ma60'] = close.rolling(60).mean()
return df