Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Services 模块
|
||||
@@ -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)
|
||||
@@ -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': []}
|
||||
@@ -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'])
|
||||
@@ -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] 访问 ~50ns,pandas 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上穿D(K-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
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 数组元素访问约 50ns,pandas 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
|
||||
Reference in New Issue
Block a user