增加 recommendation_log 与定时任务,按网格搜索结果收紧止损/目标 ATR,并补齐绩效核验接口与界面本地化。 Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
10 KiB
Python
247 lines
10 KiB
Python
"""Stock Recommendation Engine — money flow driven stock picks.
|
|
|
|
Identifies top stocks in sectors with strong capital inflow,
|
|
ranks by momentum/volume/trend, generates actionable trade plans
|
|
with entry/stop/target levels.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
|
|
import duckdb
|
|
from loguru import logger
|
|
|
|
from ashare_dp.data.store.database import analytics_conn, kline_glob
|
|
|
|
|
|
def get_recommendations(
|
|
trade_date: date,
|
|
top_n: int = 10,
|
|
min_amount_yi: float = 2.0, # 最低日成交额(亿)
|
|
) -> list[dict]:
|
|
"""Generate ranked stock recommendations based on money flow.
|
|
|
|
1. Find sectors with strongest capital inflow
|
|
2. Within each sector, rank stocks by momentum + volume + trend
|
|
3. Generate trade plans (entry, stop, targets)
|
|
|
|
Returns list of recommendation dicts sorted by composite score.
|
|
"""
|
|
parquet_glob = kline_glob()
|
|
conn = analytics_conn()
|
|
|
|
# ═══ Step 1: Find top inflow sectors ═══
|
|
try:
|
|
flow_sql = f"""
|
|
WITH normalized AS (
|
|
SELECT ts_code, trade_date, amount
|
|
FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
|
|
WHERE trade_date >= $start_date AND trade_date <= $end_date
|
|
),
|
|
daily AS (
|
|
SELECT n.trade_date, n.amount, si.industry_name
|
|
FROM normalized n
|
|
JOIN stock_industry si ON n.ts_code = si.ts_code
|
|
),
|
|
industry_daily AS (
|
|
SELECT industry_name, trade_date, SUM(amount) AS total_amount
|
|
FROM daily GROUP BY industry_name, trade_date
|
|
),
|
|
ranked AS (
|
|
SELECT *, ROW_NUMBER() OVER (PARTITION BY industry_name ORDER BY trade_date DESC) AS rn
|
|
FROM industry_daily
|
|
),
|
|
recent AS (
|
|
SELECT industry_name, SUM(total_amount) AS recent
|
|
FROM ranked WHERE rn <= 5 GROUP BY industry_name
|
|
),
|
|
prior AS (
|
|
SELECT industry_name, SUM(total_amount) AS prior
|
|
FROM ranked WHERE rn > 5 AND rn <= 10 GROUP BY industry_name
|
|
)
|
|
SELECT COALESCE(r.industry_name, p.industry_name) AS industry_name,
|
|
CASE WHEN COALESCE(p.prior,0) > 0 THEN (COALESCE(r.recent,0) - p.prior) / p.prior ELSE 0 END AS flow_change
|
|
FROM recent r FULL OUTER JOIN prior p ON r.industry_name = p.industry_name
|
|
WHERE COALESCE(r.recent, 0) + COALESCE(p.prior, 0) > 0
|
|
ORDER BY flow_change DESC
|
|
LIMIT 5
|
|
"""
|
|
end_str = trade_date.strftime("%Y-%m-%d")
|
|
from datetime import timedelta
|
|
start_str = (trade_date - timedelta(days=20)).strftime("%Y-%m-%d")
|
|
flow_df = conn.execute(
|
|
flow_sql.replace("$start_date", f"'{start_str}'").replace("$end_date", f"'{end_str}'")
|
|
).fetchdf()
|
|
top_sectors = flow_df["industry_name"].tolist() if not flow_df.empty else []
|
|
except Exception as e:
|
|
logger.warning(f"Flow sector query failed: {e}")
|
|
top_sectors = []
|
|
finally:
|
|
conn.close()
|
|
|
|
if not top_sectors:
|
|
return []
|
|
|
|
# ═══ Step 2: Find best stocks in top sectors ═══
|
|
conn = analytics_conn()
|
|
recommendations = []
|
|
|
|
for sector in top_sectors[:3]: # top 3 inflow sectors
|
|
try:
|
|
sector_list = "', '".join(top_sectors)
|
|
stock_sql = f"""
|
|
WITH normalized AS (
|
|
SELECT ts_code, trade_date, trade_time, close, volume, amount, high, low, open
|
|
FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
|
|
WHERE trade_date <= $trade_date
|
|
),
|
|
with_ma AS (
|
|
SELECT *,
|
|
AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS ma20,
|
|
AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS ma60,
|
|
AVG(volume) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS vol_ma20,
|
|
(high - low) AS day_range,
|
|
ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time DESC) AS rn
|
|
FROM normalized
|
|
),
|
|
with_atr AS (
|
|
SELECT *,
|
|
AVG(day_range) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 19 PRECEDING AND CURRENT ROW) AS atr20
|
|
FROM with_ma
|
|
),
|
|
latest AS (
|
|
SELECT w.*, si.industry_name
|
|
FROM with_atr w
|
|
JOIN stock_industry si ON w.ts_code = si.ts_code
|
|
WHERE w.rn = 1 AND si.industry_name = $sector
|
|
),
|
|
prev AS (
|
|
SELECT ts_code, close AS close_prev
|
|
FROM with_ma WHERE rn = 6
|
|
),
|
|
scored AS (
|
|
SELECT
|
|
l.ts_code, l.industry_name, l.close, l.volume, l.amount,
|
|
l.ma20, l.ma60, l.vol_ma20, l.atr20,
|
|
l.high, l.low, l.open,
|
|
p.close_prev,
|
|
-- Momentum: 5d return
|
|
(l.close - p.close_prev) / NULLIF(p.close_prev, 0) AS ret_5d,
|
|
-- Volume expansion
|
|
l.volume / NULLIF(l.vol_ma20, 0) AS vol_ratio,
|
|
-- Trend: above MAs
|
|
CASE WHEN l.close > l.ma20 THEN 1 ELSE 0 END + CASE WHEN l.close > l.ma60 THEN 1 ELSE 0 END AS trend_score
|
|
FROM latest l
|
|
LEFT JOIN prev p ON l.ts_code = p.ts_code
|
|
WHERE l.amount > $min_amt
|
|
)
|
|
SELECT *,
|
|
(COALESCE(ret_5d, 0) * 50 + LEAST(vol_ratio, 3.0) / 3.0 * 25 + trend_score * 12.5) AS composite
|
|
FROM scored
|
|
ORDER BY composite DESC
|
|
LIMIT 4
|
|
"""
|
|
min_amt = min_amount_yi * 1e8
|
|
stock_df = conn.execute(
|
|
stock_sql,
|
|
{"trade_date": trade_date.isoformat(), "sector": sector, "min_amt": min_amt},
|
|
).fetchdf()
|
|
|
|
# Look up stock names
|
|
stock_names = {}
|
|
try:
|
|
nc = duckdb.connect("data/duckdb/ashare.db", read_only=True)
|
|
for c in stock_df["ts_code"].tolist():
|
|
r = nc.execute("SELECT name FROM stock_info WHERE ts_code=?", [c]).fetchone()
|
|
stock_names[c] = r[0] if r else c
|
|
nc.close()
|
|
except Exception:
|
|
pass
|
|
|
|
for _, row in stock_df.iterrows():
|
|
import math
|
|
entry = float(row["close"])
|
|
atr_raw = row.get("atr20")
|
|
atr = float(atr_raw) if atr_raw and not (isinstance(atr_raw, float) and math.isnan(atr_raw)) else entry * 0.03
|
|
|
|
# Stop: 2.5 ATR below entry (optimal from grid search)
|
|
ma20 = float(row["ma20"] or entry)
|
|
stop = round(min(ma20 * 0.97, entry - 2.5 * atr), 2)
|
|
|
|
# Targets: 3.0 ATR (optimal), secondary 4.0 ATR
|
|
target1 = round(entry + 3.0 * atr, 2)
|
|
target2 = round(entry + 4.0 * atr, 2)
|
|
|
|
# Risk/reward
|
|
risk = entry - stop
|
|
reward = target1 - entry
|
|
rr_ratio = round(reward / risk, 1) if risk > 0 else 0
|
|
|
|
# Position sizing guidance
|
|
position_advice = "Standard"
|
|
if float(row["trend_score"]) >= 2:
|
|
position_advice = "Aggressive"
|
|
elif float(row.get("vol_ratio", 1)) < 0.8:
|
|
position_advice = "Reduced"
|
|
|
|
import math as _m
|
|
def _safe(v, d):
|
|
try:
|
|
f = float(v)
|
|
return d if _m.isnan(f) else f
|
|
except (ValueError, TypeError):
|
|
return d
|
|
_ret5 = _safe(row.get("ret_5d"), 0.0)
|
|
_volr = _safe(row.get("vol_ratio"), 1.0)
|
|
_trnd = _safe(row.get("trend_score"), 0.0)
|
|
_comp = _safe(row.get("composite"), 0.0)
|
|
if _m.isnan(_ret5): _ret5 = 0.0
|
|
if _m.isnan(_volr): _volr = 1.0
|
|
if _m.isnan(_trnd): _trnd = 0.0
|
|
if _m.isnan(_comp): _comp = 0.0
|
|
|
|
ts = row["ts_code"]
|
|
recommendations.append({
|
|
"ts_code": ts,
|
|
"name": stock_names.get(ts, ts),
|
|
"sector": sector,
|
|
"entry": round(entry, 2),
|
|
"stop": stop,
|
|
"target1": target1,
|
|
"target2": target2,
|
|
"rr_ratio": round(float(rr_ratio), 1) if not _m.isnan(rr_ratio) else 0.0,
|
|
"position": position_advice,
|
|
"score": round(_comp, 1),
|
|
"metrics": {
|
|
"ret_5d": round(_ret5 * 100, 1),
|
|
"vol_ratio": round(_volr, 2),
|
|
"trend": "Strong" if _trnd >= 2 else "Weak",
|
|
"amount_yi": round(float(row["amount"]) / 1e8, 1),
|
|
},
|
|
"action": _recommend_action(_ret5, _volr, _trnd),
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Stock scoring failed for sector {sector}: {e}")
|
|
|
|
conn.close()
|
|
|
|
# Sort by composite score
|
|
recommendations.sort(key=lambda r: r["score"], reverse=True)
|
|
return recommendations[:top_n]
|
|
|
|
|
|
def _recommend_action(ret_5d: float, vol_ratio: float, trend_score: float) -> str:
|
|
"""Generate trading action recommendation."""
|
|
if trend_score >= 2 and ret_5d > 0.03 and vol_ratio > 1.2:
|
|
return "买入 — 趋势强势,放量上涨"
|
|
elif trend_score >= 2 and ret_5d > 0:
|
|
return "关注 — 趋势健康,等待回踩"
|
|
elif trend_score >= 1 and vol_ratio > 1.0:
|
|
return "观察 — 趋势形成中,可轻仓试"
|
|
elif ret_5d < -0.03:
|
|
return "回避 — 短期偏弱,等待企稳"
|
|
else:
|
|
return "观望 — 方向不明,暂不参与"
|