v10: Trading OS — 5-subsystem architecture + Signal Intelligence + Dashboard Command Center
Architecture: - Restructure into 5 subsystems: data/, features/, market/, signals/, execution/, apps/ - Unified ts_code conversion in core/codes.py (idempotent, kills 4 duplicate copies) - analytics_conn() + kline_glob() — zero hardcoded DB/Parquet paths - Fixed double-suffix bug (.SZ.SZ) in backfill pipeline root cause Signal Intelligence (the moat): - 14 signal types: EMA52, Vegas, Chan, ORB, Gap, NR7, Inside Bar - 640K+ historical signal instances across 8 backfilled types - Multi-signal Expectancy Engine with breadth-similarity matching - Signal backfill CLI: ashare-dp backfill signals Market Intelligence: - 8 engines: State, Leadership, Opportunity, Flow, Sentiment, Memory, Knowledge Graph, Recommendations - Real limit-up/down sentiment via akshare (108 ZT, 19 DT, 52 broken board) - Knowledge Graph: 8 themes × 30+ concepts with keyword matching - Money-flow stock recommendations with entry/stop/target trade plans Dashboard Command Center: - Decision-first layout: COMMAND → WHERE → WHY → RISK → EXPECTANCY - Multi-signal Expectancy comparison table (8 types ranked by WR) - Theme Map visualization with rotation detection - Intraday Replay infrastructure (30min state snapshots) - RECOMMENDATIONS card with actionable trade plans Trading Memory: - trade_log table + POST/GET/PUT API for trade recording - Performance stats aggregation Code Quality: - 0 hardcoded DB paths, 0 REPLACE hacks, 0 dead ts_code copies - EMA52 screening deduplicated (CLI + scheduler share one function) - read_parquet_sql() helper for 28 duplicate patterns - 6 bugs fixed from code review (NR7 window, theme matching, column indices, etc.) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
"""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) * 40 + LEAST(vol_ratio, 3.0) / 3.0 * 30 + trend_score * 15) 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: MA20 or 2 ATR below entry
|
||||
ma20 = float(row["ma20"] or entry)
|
||||
stop = round(min(ma20 * 0.97, entry - 2 * atr), 2)
|
||||
|
||||
# Targets
|
||||
target1 = round(entry + 1.5 * atr, 2)
|
||||
target2 = round(entry + 3.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 "观望 — 方向不明,暂不参与"
|
||||
Reference in New Issue
Block a user