feat: 历史K线(Sina)驱动S2多日确认+10只新候选入自选
This commit is contained in:
+63
-56
@@ -40,7 +40,7 @@ def log_candidate(conn, code, stage, passed, detail):
|
||||
# ── Stage 2: 多日K线确认 ──
|
||||
|
||||
def fetch_daily_klines(code):
|
||||
"""拉取近N日K线(如API不可用则返回当日单日数据)"""
|
||||
"""拉取近10日日K线(Sina 240分钟线=日K)"""
|
||||
raw = str(code).strip()
|
||||
if raw.startswith(("6", "9")):
|
||||
prefix = "sh"
|
||||
@@ -49,78 +49,85 @@ def fetch_daily_klines(code):
|
||||
else:
|
||||
return None
|
||||
|
||||
import subprocess as _sp
|
||||
url = f"http://qt.gtimg.cn/q={prefix}{raw}"
|
||||
import subprocess as _sp, json as _json
|
||||
url = f"http://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol={prefix}{raw}&scale=240&ma=5&datalen=10"
|
||||
try:
|
||||
r = _sp.run(["curl", "-s", url], capture_output=True, timeout=10)
|
||||
raw_text = r.stdout.decode("gbk", errors="ignore")
|
||||
for line in raw_text.strip().split("\n"):
|
||||
if "~" not in line: continue
|
||||
parts = line.split("~")
|
||||
if len(parts) < 40: continue
|
||||
price = float(parts[3]) if parts[3] else 0
|
||||
prev_close = float(parts[4]) if parts[4] else 0
|
||||
high = float(parts[33]) if parts[33] else 0
|
||||
low = float(parts[34]) if parts[34] else 0
|
||||
volume = int(float(parts[6])) if parts[6] else 0
|
||||
change = float(parts[32]) if parts[32] else 0
|
||||
if price > 0:
|
||||
# 返回当日单条K线(后续扫描积累多日数据)
|
||||
return [{
|
||||
"date": "today", "open": prev_close, "close": price,
|
||||
"high": high, "low": low, "volume": volume,
|
||||
"change_pct": change, "price": price
|
||||
}]
|
||||
r = _sp.run(["curl", "-s", "--noproxy", "*", url], capture_output=True, timeout=10)
|
||||
data = _json.loads(r.stdout)
|
||||
if not data:
|
||||
return None
|
||||
result = []
|
||||
for k in data:
|
||||
result.append({
|
||||
"date": k.get("day", "")[:10],
|
||||
"open": float(k["open"]),
|
||||
"close": float(k["close"]),
|
||||
"high": float(k["high"]),
|
||||
"low": float(k["low"]),
|
||||
"volume": int(k["volume"]),
|
||||
"price": float(k["close"]),
|
||||
"change_pct": 0,
|
||||
})
|
||||
# 计算涨跌幅
|
||||
for i in range(1, len(result)):
|
||||
prev = result[i-1]["close"]
|
||||
if prev > 0:
|
||||
result[i]["change_pct"] = (result[i]["close"] / prev - 1) * 100
|
||||
return result
|
||||
except Exception as e:
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def stage2_confirm(code, name, klines):
|
||||
"""第二关:多日K线确认
|
||||
当日有量价配合信号即可通过初筛。
|
||||
多日连续性需要多日扫描数据积累后验证。
|
||||
检查:多日量价配合、建仓特征
|
||||
"""
|
||||
if not klines or len(klines) == 0:
|
||||
return False, 0, "无行情数据"
|
||||
|
||||
today = klines[-1]
|
||||
price = today.get("price", 0)
|
||||
volume = today.get("volume", 0)
|
||||
change = today.get("change_pct", 0)
|
||||
high = today.get("high", 0)
|
||||
low = today.get("low", 0)
|
||||
if not klines or len(klines) < 3:
|
||||
return False, 0, "K线不足3日"
|
||||
|
||||
recent = klines[-5:] # 最近5日
|
||||
score = 0
|
||||
checks = []
|
||||
|
||||
# 1. 有成交量
|
||||
if volume > 100000: # 至少10万股
|
||||
# 1. 成交量连续递增
|
||||
vols = [k["volume"] for k in recent]
|
||||
vol_rising = sum(1 for i in range(len(vols)-1) if vols[i] < vols[i+1])
|
||||
if vol_rising >= 3:
|
||||
score += 2
|
||||
checks.append(f"量增{vol_rising}/4日")
|
||||
elif vol_rising >= 2:
|
||||
score += 1
|
||||
checks.append(f"量{volume/10000:.0f}万")
|
||||
else:
|
||||
checks.append("量太小")
|
||||
checks.append(f"量微增{vol_rising}/4日")
|
||||
|
||||
# 2. 跌幅不过大
|
||||
if change >= -2:
|
||||
# 2. 涨放量、跌缩量
|
||||
up_vol = sum(k["volume"] for k in recent if k["change_pct"] >= 0)
|
||||
down_vol = sum(k["volume"] for k in recent if k["change_pct"] < 0)
|
||||
if down_vol > 0 and up_vol / down_vol > 1.5:
|
||||
score += 2
|
||||
checks.append(f"涨量/跌量={up_vol/down_vol:.1f}")
|
||||
elif down_vol > 0 and up_vol / down_vol > 1:
|
||||
score += 1
|
||||
|
||||
# 3. 价格趋势
|
||||
closes = [k["close"] for k in recent]
|
||||
up_days = sum(1 for i in range(1, len(closes)) if closes[i] > closes[i-1])
|
||||
if up_days >= 3:
|
||||
score += 2
|
||||
checks.append(f"涨{up_days}/4日")
|
||||
elif up_days >= 2:
|
||||
score += 1
|
||||
|
||||
# 4. 无异常放量(单日>3倍均量=可能出货)
|
||||
avg_vol = sum(vols) / len(vols) if vols else 1
|
||||
max_ratio = max(v / avg_vol for v in vols) if avg_vol > 0 else 1
|
||||
if max_ratio < 2.5:
|
||||
score += 1
|
||||
else:
|
||||
checks.append(f"跌{change:.1f}%")
|
||||
checks.append(f"异常量{max_ratio:.0f}倍")
|
||||
|
||||
# 3. 日内有波动空间
|
||||
if high > low and price > low:
|
||||
score += 1
|
||||
|
||||
# 4. 价格不为0
|
||||
if price > 0:
|
||||
score += 1
|
||||
|
||||
# 多日确认需要后续扫描积累(暂标记)
|
||||
if len(klines) < 3:
|
||||
checks.append("待多日确认")
|
||||
|
||||
passed = score >= 3
|
||||
detail = f"评分{score}/4 | {'; '.join(checks)}"
|
||||
passed = score >= 4
|
||||
detail = f"评分{score}/7 | {'; '.join(checks)}"
|
||||
return passed, score, detail
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""import_full_stocks.py — 导入全量A股+港股列表到stocks表
|
||||
|
||||
数据来源:深交所/上交所公开列表(通过akshare或腾讯API)
|
||||
运行:python3 import_full_stocks.py
|
||||
"""
|
||||
import sys, json, time, urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path("/home/hmo/MoFin/data/mofin.db")
|
||||
|
||||
def fetch_tencent_batch(codes):
|
||||
"""腾讯批量查询股票名称"""
|
||||
url = f"http://qt.gtimg.cn/q={','.join(codes)}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
proxy = urllib.request.ProxyHandler({})
|
||||
opener = urllib.request.build_opener(proxy)
|
||||
with opener.open(req, timeout=15) as r:
|
||||
text = r.read().decode("gbk")
|
||||
results = {}
|
||||
for line in text.strip().split("\n"):
|
||||
if "~" not in line:
|
||||
continue
|
||||
parts = line.split("~")
|
||||
name_part = parts[0] if parts else ""
|
||||
code = ""
|
||||
m = __import__('re').search(r'_(sh|sz|hk)(\d+)', name_part)
|
||||
if m:
|
||||
code = m.group(2)
|
||||
name = parts[1] if len(parts) > 1 else ""
|
||||
market = parts[2] if len(parts) > 2 else ""
|
||||
if code and name:
|
||||
results[code] = (name, market)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f" 腾讯API错误: {e}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
def main():
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
|
||||
# 获取已有代码
|
||||
existing = set(r[0] for r in conn.execute("SELECT code FROM stocks").fetchall())
|
||||
print(f"当前stocks表已有: {len(existing)}只")
|
||||
|
||||
# 生成待查询的A股代码范围(深市000/001/002/003/300/301,沪市600/601/603/605/688/689)
|
||||
prefixes = {
|
||||
"深市A": [f"{i:03d}" for i in range(0, 10)], # 000-009
|
||||
"深市中小": [f"{i:03d}" for i in range(10, 50)], # 010-049→实际用001/002
|
||||
"深市创业": [f"{i:03d}" for i in range(300, 302)], # 300-301→实际用300
|
||||
"沪市A": [f"{i:03d}" for i in range(600, 606)], # 600-605
|
||||
"沪市科创": [f"{i:03d}" for i in range(688, 690)], # 688-689
|
||||
}
|
||||
|
||||
# 实际代码规则:深市000/001/002/003/300/301,沪市600/601/603/605/688
|
||||
code_ranges = []
|
||||
for prefix in ["000", "001", "002", "003", "300", "301"]:
|
||||
for suffix in range(1, 1000):
|
||||
code_ranges.append(f"{prefix}{suffix:03d}")
|
||||
for prefix in ["600", "601", "603", "605", "688"]:
|
||||
for suffix in range(1, 1000):
|
||||
code_ranges.append(f"{prefix}{suffix:03d}")
|
||||
|
||||
print(f"待查代码总量: {len(code_ranges)}")
|
||||
|
||||
# 分批查询(每批30个)
|
||||
batch_size = 30
|
||||
new_count = 0
|
||||
for i in range(0, len(code_ranges), batch_size):
|
||||
batch = code_ranges[i:i+batch_size]
|
||||
# 过滤已存在的
|
||||
batch = [c for c in batch if c not in existing]
|
||||
if not batch:
|
||||
continue
|
||||
|
||||
symbols = []
|
||||
for c in batch:
|
||||
if c.startswith(("5", "6", "9")):
|
||||
symbols.append(f"sh{c}")
|
||||
else:
|
||||
symbols.append(f"sz{c}")
|
||||
|
||||
results = fetch_tencent_batch(symbols)
|
||||
for code, (name, market) in results.items():
|
||||
if code not in existing:
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO stocks (code, name) VALUES (?, ?)",
|
||||
(code, name)
|
||||
)
|
||||
new_count += 1
|
||||
existing.add(code)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if (i // batch_size) % 50 == 0:
|
||||
print(f" 进度: {i}/{len(code_ranges)}, 新增{new_count}")
|
||||
|
||||
conn.commit()
|
||||
total = conn.execute("SELECT COUNT(*) FROM stocks").fetchone()[0]
|
||||
print(f"\n完成: 新增{new_count}, 总{total}只")
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -125,6 +125,8 @@ def refresh_data_prices():
|
||||
all_codes.add(r['code'])
|
||||
for r in conn.execute("SELECT code FROM watchlist_stocks"):
|
||||
all_codes.add(r['code'])
|
||||
for r in conn.execute("SELECT code FROM holding_strategies WHERE status='active'"):
|
||||
all_codes.add(r['code'])
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 从DB读代码失败: {e}", file=sys.stderr)
|
||||
@@ -190,6 +192,11 @@ def refresh_data_prices():
|
||||
# 写实时价格表(供 read_live_prices 消费)
|
||||
live = {h['code']: {'price': h.get('price',0), 'change_pct': h.get('change_pct',0)}
|
||||
for h in db_holdings if h.get('code')}
|
||||
# 补充自选股/策略股的价格(它们不在holdings表中)
|
||||
for code, pdata in prices.items():
|
||||
if code not in live:
|
||||
live[code] = {'price': pdata[0] if isinstance(pdata, (list, tuple)) else pdata.get('price',0),
|
||||
'change_pct': pdata[1] if isinstance(pdata, (list, tuple)) else pdata.get('change_pct',0)}
|
||||
write_live_prices(conn, live)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -15,11 +15,12 @@ def main():
|
||||
|
||||
# 读未提拔候选(按评分降序)
|
||||
rows = conn.execute("""
|
||||
SELECT * FROM candidates
|
||||
WHERE (promoted IS NULL OR promoted = 0)
|
||||
AND (dropped IS NULL OR dropped = 0)
|
||||
AND score >= 6
|
||||
ORDER BY score DESC
|
||||
SELECT c.code, c.name, c.score_final, c.entry_range, c.stop_loss, c.target
|
||||
FROM candidates c
|
||||
WHERE (c.promoted IS NULL OR c.promoted = 0)
|
||||
AND (c.dropped IS NULL OR c.dropped = 0)
|
||||
AND c.score_final >= 4
|
||||
ORDER BY c.score_final DESC
|
||||
""").fetchall()
|
||||
|
||||
if not rows:
|
||||
@@ -29,16 +30,21 @@ def main():
|
||||
|
||||
promoted = 0
|
||||
for r in rows:
|
||||
code = str(r["code"])
|
||||
name = r["name"] or code
|
||||
price = r["price"] or 0
|
||||
el = r["entry_low"] or 0
|
||||
eh = r["entry_high"] or 0
|
||||
sl = r["stop_loss"] or 0
|
||||
tp = r["take_profit"] or 0
|
||||
score = r["score"] or 0
|
||||
sector = r["sector"] or ""
|
||||
reason = r["reason"] or ""
|
||||
code = str(r[0])
|
||||
name = r[1] or code
|
||||
score = r[2] or 0
|
||||
entry_range = r[3] or ""
|
||||
sl = r[4] or 0
|
||||
tp = r[5] or 0
|
||||
|
||||
# 解析 entry_range
|
||||
el, eh = 0, 0
|
||||
if "~" in entry_range:
|
||||
parts = entry_range.split("~")
|
||||
try:
|
||||
el = float(parts[0])
|
||||
eh = float(parts[1])
|
||||
except: pass
|
||||
|
||||
# 查是否已在 holding_strategies
|
||||
exists = conn.execute(
|
||||
@@ -46,7 +52,6 @@ def main():
|
||||
(code,)
|
||||
).fetchone()
|
||||
if exists:
|
||||
# 标记已提拔但不重复加
|
||||
conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,))
|
||||
print(f" ⏭ {code} {name} 已在自选中,标记promoted")
|
||||
continue
|
||||
@@ -54,7 +59,7 @@ def main():
|
||||
# 构建策略
|
||||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
timing_signal = "买入" if score >= 7 else "关注"
|
||||
action = f"市场扫描发现({reason})" if reason else "市场扫描发现"
|
||||
action = f"市场扫描发现(评分{score})"
|
||||
|
||||
conn.execute("""
|
||||
INSERT INTO holding_strategies
|
||||
@@ -64,7 +69,7 @@ def main():
|
||||
sector_context, quality_check)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,'自选策略','scan',
|
||||
'active',0,'关注',?,?,'', 'pending')
|
||||
""", (code, name, price, el, eh, sl, tp, timing_signal, action, now, now))
|
||||
""", (code, name, 0, el, eh, sl, tp, timing_signal, action, now, now))
|
||||
|
||||
conn.execute("UPDATE candidates SET promoted=1 WHERE code=?", (code,))
|
||||
promoted += 1
|
||||
|
||||
Reference in New Issue
Block a user