diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f8f6fe9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + tushare: + build: ./tushare + env_file: ./tushare/.env + environment: + - DB_PATH=/app/data/futures.db + volumes: + - ./data:/app/data + command: ["python", "-m", "src.main", "FG2609.ZCE"] diff --git a/tushare/.dockerignore b/tushare/.dockerignore new file mode 100644 index 0000000..0f5d115 --- /dev/null +++ b/tushare/.dockerignore @@ -0,0 +1,26 @@ +__pycache__ +*.py[cod] +*$py.class +.venv +venv +env +.env +.env.* +!.env.example +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +.ipynb_checkpoints +.git +.gitignore +.idea +.vscode +*.log +*.csv +*.parquet +*.db +*.sqlite* +data/ +cache/ diff --git a/tushare/Dockerfile b/tushare/Dockerfile new file mode 100644 index 0000000..a381b1d --- /dev/null +++ b/tushare/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.13.7-alpine3.22 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + TZ=Asia/Shanghai \ + DB_PATH=/app/data/futures.db + +WORKDIR /app + +# 运行时依赖 + 时区 +RUN apk add --no-cache tzdata \ + && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && echo "Asia/Shanghai" > /etc/timezone + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 非 root 用户 +RUN adduser -D -u 1000 app \ + && mkdir -p /app/data \ + && chown app:app /app/data + +COPY --chown=app:app src ./src +USER app + +CMD ["python", "-m", "src.main", "FG2609.ZCE"] diff --git a/tushare/main.py b/tushare/main.py new file mode 100644 index 0000000..dec1fd3 --- /dev/null +++ b/tushare/main.py @@ -0,0 +1,23 @@ +import os +import sys + +import tushare as ts + + +def main() -> int: + token = os.environ.get("TUSHARE_TOKEN") + if not token: + print("[ERROR] 未设置 TUSHARE_TOKEN 环境变量", file=sys.stderr) + return 1 + + ts.set_token(token) + pro = ts.pro_api() + + df = pro.trade_cal(exchange="SHFE", start_date="20260101", end_date="20260110") + print(f"[OK] tushare 连通,返回 {len(df)} 行交易日历样本") + print(df.head()) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tushare/requirements.txt b/tushare/requirements.txt new file mode 100644 index 0000000..8ce90c6 --- /dev/null +++ b/tushare/requirements.txt @@ -0,0 +1,3 @@ +tushare>=1.4.0 +pandas>=2.2.0 +requests>=2.31.0 diff --git a/tushare/src/__init__.py b/tushare/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tushare/src/fetcher.py b/tushare/src/fetcher.py new file mode 100644 index 0000000..b1bc7d9 --- /dev/null +++ b/tushare/src/fetcher.py @@ -0,0 +1,36 @@ +import os +from typing import Optional + +import pandas as pd +import tushare as ts + + +def _init_api(): + token = os.environ.get("TUSHARE_TOKEN") + if not token: + raise RuntimeError("TUSHARE_TOKEN 环境变量未设置") + ts.set_token(token) + return ts.pro_api() + + +def fetch_contract(ts_code: str, limit: int = 100) -> pd.DataFrame: + """拉取指定期货合约的日线数据,返回按 trade_date 升序排列的 DataFrame。""" + pro = _init_api() + df = pro.fut_daily(ts_code=ts_code) + + if df.empty: + raise RuntimeError(f"未返回 {ts_code} 的任何数据,可能合约代码错误或 token 积分不足") + + cols = [ + "ts_code", "trade_date", "open", "high", "low", + "close", "vol", "amount", "oi", "oi_chg", "pre_close", + ] + df = df[[c for c in cols if c in df.columns]].copy() + + numeric = ["open", "high", "low", "close", "vol", "amount", "oi", "oi_chg", "pre_close"] + for c in numeric: + if c in df.columns: + df[c] = pd.to_numeric(df[c], errors="coerce") + + df = df.sort_values("trade_date").reset_index(drop=True) + return df diff --git a/tushare/src/main.py b/tushare/src/main.py new file mode 100644 index 0000000..af29e52 --- /dev/null +++ b/tushare/src/main.py @@ -0,0 +1,76 @@ +import argparse +import sys + +from . import fetcher, scorer, storage + + +def run(ts_code: str) -> int: + storage.init_db() + + print(f"[1/4] 拉取 {ts_code} 数据...") + df = fetcher.fetch_contract(ts_code) + print(f" 返回 {len(df)} 行") + + print(f"[2/4] 写入/更新 SQLite...") + storage.save_candles(df) + + print(f"[3/4] 计算打分...") + result = scorer.score_daily(df) + + print(f"[4/4] 保存打分结果...") + storage.save_score(result) + + # 输出 + print("\n" + "=" * 65) + print(f"合约: {result.ts_code:<20} 日期: {result.trade_date}") + print(f"收盘: {result.close:>10.2f} 持仓: {result.oi:>12,.0f}") + print(f"持仓变动: {result.oi_chg:>+8,.0f}") + print("=" * 65) + + print(f"\n{'模块':<12} {'分数':>8} {'权重':>6} {'加权':>8}") + print("-" * 40) + print(f"{'短期动力':<12} {result.short_term:>8.1f} {0.4:>6.2f} {result.short_term * 0.4:>8.2f}") + print(f"{'中期趋势':<12} {result.medium_term:>8.1f} {0.35:>6.2f} {result.medium_term * 0.35:>8.2f}") + print(f"{'长期结构':<12} {result.long_term:>8.1f} {0.25:>6.2f} {result.long_term * 0.25:>8.2f}") + print("-" * 40) + print(f"{'综合分数':<12} {result.composite:>8.1f}") + print(f"\n信号: {result.signal}") + print("=" * 65) + + print("\n[短期动力] 近7日逐日打分:") + print("-" * 65) + for d in result.detail.short_details: + tag = "增仓" if d["oi_chg"] > 0 else "减仓" + if abs(d["oi_chg"] / d["oi"]) < 0.01: + tag = "持平" + price_dir = "涨" if d["close"] >= d["pre_close"] else "跌" + print(f" {d['trade_date']} {tag:>4} + {price_dir} " + f"持仓{d['oi_chg']:>+8,.0f} 得分: {d['score']:>3}") + + md = result.detail.medium_detail + print(f"\n[中期趋势] 明细:") + print(f" 15日价格收益率: {md['price_return_pct']:+.2f}%") + print(f" 价格信号得分: {md['price_signal']:.1f}") + print(f" 增仓上涨天数: {md['long_up_days']} 天") + print(f" 增仓下跌天数: {md['long_down_days']} 天") + print(f" 资金意愿得分: {md['fund_signal']} 分") + + ld = result.detail.long_detail + print(f"\n[长期结构] 明细:") + print(f" 近30日日均持仓: {ld['avg_oi']:,.0f}") + print(f" 30日前持仓量: {ld['oi_before']:,.0f}") + print(f" 持仓变化幅度: {ld['change_pct']:+.2f}%") + + print(f"\n[OK] 数据已持久化到 SQLite") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="期货合约三层打分模型") + parser.add_argument("ts_code", help="合约代码,如 FG2609.ZCE") + args = parser.parse_args() + return run(args.ts_code) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tushare/src/models.py b/tushare/src/models.py new file mode 100644 index 0000000..8434a6d --- /dev/null +++ b/tushare/src/models.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class Candle: + ts_code: str + trade_date: str + open: float + high: float + low: float + close: float + vol: float + amount: float + oi: float + oi_chg: float + pre_close: Optional[float] = None + + +@dataclass +class ScoreDetail: + short_details: list = field(default_factory=list) + medium_detail: dict = field(default_factory=dict) + long_detail: dict = field(default_factory=dict) + + +@dataclass +class ScoreResult: + ts_code: str + trade_date: str + close: float + oi: float + oi_chg: float + short_term: float + medium_term: float + long_term: float + composite: float + signal: str + detail: ScoreDetail diff --git a/tushare/src/scorer.py b/tushare/src/scorer.py new file mode 100644 index 0000000..2b1d6f8 --- /dev/null +++ b/tushare/src/scorer.py @@ -0,0 +1,149 @@ +import pandas as pd + +from .models import ScoreDetail, ScoreResult + + +def _daily_short_score(row: pd.Series) -> int: + """单日短期动力打分。""" + oi = float(row["oi"]) + oi_chg = float(row["oi_chg"]) + close = float(row["close"]) + pre_close = float(row["pre_close"]) + + oi_change_pct = abs(oi_chg / oi) if oi != 0 else 0 + price_up = close >= pre_close + + if oi_change_pct < 0.01: + return 60 if price_up else 40 + + oi_increasing = oi_chg > 0 + if oi_increasing and price_up: + return 100 + if oi_increasing and not price_up: + return 0 + if not oi_increasing and price_up: + return 70 + return 30 + + +def calc_short_term(df: pd.DataFrame, window: int = 7) -> tuple[float, list]: + recent = df.iloc[-window:].copy() + scores = [] + details = [] + for _, row in recent.iterrows(): + score = _daily_short_score(row) + scores.append(score) + details.append({ + "trade_date": str(row["trade_date"]), + "close": float(row["close"]), + "pre_close": float(row["pre_close"]), + "oi": float(row["oi"]), + "oi_chg": float(row["oi_chg"]), + "score": score, + }) + return sum(scores) / len(scores), details + + +def calc_medium_term(df: pd.DataFrame, window: int = 15) -> tuple[float, dict]: + if len(df) < window + 1: + raise ValueError(f"数据不足,需要至少 {window + 1} 行") + + recent = df.iloc[-window:].copy() + close_now = float(df.iloc[-1]["close"]) + close_before = float(df.iloc[-window - 1]["close"]) + + price_return = (close_now - close_before) / close_before if close_before != 0 else 0 + price_score = max(0.0, min(100.0, 50.0 + price_return * 500)) + + long_up = 0 + long_down = 0 + for _, row in recent.iterrows(): + if row["oi_chg"] > 0: + if row["close"] >= row["pre_close"]: + long_up += 1 + else: + long_down += 1 + + fund_score = 80 if long_up > long_down else (20 if long_up < long_down else 50) + score = price_score * 0.6 + fund_score * 0.4 + + detail = { + "price_return_pct": round(price_return * 100, 2), + "price_signal": round(price_score, 1), + "long_up_days": long_up, + "long_down_days": long_down, + "fund_signal": fund_score, + } + return score, detail + + +def calc_long_term(df: pd.DataFrame, window: int = 30) -> tuple[float, dict]: + if len(df) < window + 1: + raise ValueError(f"数据不足,需要至少 {window + 1} 行") + + recent_oi = df.iloc[-window:]["oi"] + avg_oi = recent_oi.mean() + oi_before = float(df.iloc[-window - 1]["oi"]) + + change_pct = (avg_oi - oi_before) / oi_before if oi_before != 0 else 0 + + if change_pct > 0.10: + score = 90 + elif change_pct > 0.05: + score = 70 + elif change_pct > -0.05: + score = 50 + elif change_pct > -0.10: + score = 30 + else: + score = 10 + + detail = { + "avg_oi": round(float(avg_oi), 0), + "oi_before": round(oi_before, 0), + "change_pct": round(change_pct * 100, 2), + } + return score, detail + + +def _interpret(composite: float) -> str: + if composite >= 80: + return "强烈看多区域 — 价格与资金共振,趋势多头的温床" + if composite >= 50: + return "偏多/震荡偏强 — 上涨但资金犹豫,或空头离场反弹" + if composite >= 40: + return "偏空/震荡偏弱 — 多头止损,或缺乏资金的阴跌" + return "强烈看空区域 — 资金主动且持续地打压价格" + + +def score_daily(df: pd.DataFrame) -> ScoreResult: + """对 DataFrame 中最新一条记录打分。""" + if len(df) < 31: + raise ValueError(f"数据量不足(仅 {len(df)} 行),需要至少 31 行") + + latest = df.iloc[-1] + + short, short_details = calc_short_term(df, 7) + medium, medium_detail = calc_medium_term(df, 15) + long_, long_detail = calc_long_term(df, 30) + + composite = short * 0.4 + medium * 0.35 + long_ * 0.25 + signal = _interpret(composite) + + return ScoreResult( + ts_code=str(latest["ts_code"]), + trade_date=str(latest["trade_date"]), + close=float(latest["close"]), + oi=float(latest["oi"]), + oi_chg=float(latest["oi_chg"]), + short_term=round(short, 1), + medium_term=round(medium, 1), + long_term=round(long_, 1), + composite=round(composite, 1), + signal=signal, + detail=ScoreDetail( + short_details=short_details, + medium_detail=medium_detail, + long_detail=long_detail, + ), + ) diff --git a/tushare/src/storage.py b/tushare/src/storage.py new file mode 100644 index 0000000..42a634a --- /dev/null +++ b/tushare/src/storage.py @@ -0,0 +1,131 @@ +import json +import os +import sqlite3 +from typing import Optional + +import pandas as pd + +from .models import ScoreResult + +DEFAULT_DB_PATH = os.environ.get("DB_PATH", "/app/data/futures.db") + + +def _get_conn(db_path: str = DEFAULT_DB_PATH) -> sqlite3.Connection: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(db_path: str = DEFAULT_DB_PATH): + """初始化数据库,创建 candles 和 scores 表。""" + os.makedirs(os.path.dirname(db_path), exist_ok=True) + conn = _get_conn(db_path) + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute(""" + CREATE TABLE IF NOT EXISTS candles ( + ts_code TEXT NOT NULL, + trade_date TEXT NOT NULL, + open REAL, + high REAL, + low REAL, + close REAL, + vol REAL, + amount REAL, + oi REAL, + oi_chg REAL, + pre_close REAL, + PRIMARY KEY (ts_code, trade_date) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS scores ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts_code TEXT NOT NULL, + trade_date TEXT NOT NULL, + close REAL, + oi REAL, + oi_chg REAL, + short_term REAL, + medium_term REAL, + long_term REAL, + composite REAL, + signal TEXT, + detail_json TEXT, + created_at TEXT DEFAULT (datetime('now', 'localtime')), + UNIQUE (ts_code, trade_date) + ) + """) + conn.commit() + finally: + conn.close() + + +def save_candles(df: pd.DataFrame, db_path: str = DEFAULT_DB_PATH): + """批量写入/更新日线数据。""" + if df.empty: + return + conn = _get_conn(db_path) + try: + df = df.copy() + df = df.where(pd.notna(df), None) + records = df.to_dict(orient="records") + conn.executemany( + """ + INSERT OR REPLACE INTO candles + (ts_code, trade_date, open, high, low, close, vol, amount, oi, oi_chg, pre_close) + VALUES (:ts_code, :trade_date, :open, :high, :low, :close, + :vol, :amount, :oi, :oi_chg, :pre_close) + """, + records, + ) + conn.commit() + finally: + conn.close() + + +def save_score(score: ScoreResult, db_path: str = DEFAULT_DB_PATH): + """写入打分结果。""" + conn = _get_conn(db_path) + try: + conn.execute( + """ + INSERT OR REPLACE INTO scores + (ts_code, trade_date, close, oi, oi_chg, + short_term, medium_term, long_term, composite, signal, detail_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + score.ts_code, + score.trade_date, + score.close, + score.oi, + score.oi_chg, + score.short_term, + score.medium_term, + score.long_term, + score.composite, + score.signal, + json.dumps({ + "short_details": score.detail.short_details, + "medium_detail": score.detail.medium_detail, + "long_detail": score.detail.long_detail, + }, ensure_ascii=False, default=str), + ), + ) + conn.commit() + finally: + conn.close() + + +def get_latest_score(ts_code: str, db_path: str = DEFAULT_DB_PATH) -> Optional[dict]: + """查询最新打分记录。""" + conn = _get_conn(db_path) + try: + row = conn.execute( + "SELECT * FROM scores WHERE ts_code = ? ORDER BY trade_date DESC LIMIT 1", + (ts_code,), + ).fetchone() + return dict(row) if row else None + finally: + conn.close() diff --git a/使用说明.md b/使用说明.md new file mode 100644 index 0000000..0a7fd76 --- /dev/null +++ b/使用说明.md @@ -0,0 +1,161 @@ +# 期货行情分析系统 — 使用说明 + +基于 Docker + Python(tushare) + Go 的中国期货行情分析系统。当前阶段已实现数据采集与三层加权打分模型。 + +## 环境准备 + +- Docker >= 20.10 +- Docker Compose >= 2.0 +- (可选) sqlite3 CLI 用于本地查库 + +## 快速开始 + +### 1. 配置 tushare token + +将 token 写入 `tushare/.env`: + +```bash +echo "TUSHARE_TOKEN=你的token" > tushare/.env +``` + +该文件已被 gitignore 排除,不会进入版本库。 + +### 2. 启动并跑默认合约 + +```bash +docker-compose run --rm tushare +``` + +默认执行 `FG2609.ZCE`(玻璃期货 2609 合约),流程: +1. 从 tushare 拉取合约日线数据 +2. 写入 SQLite `data/futures.db` +3. 运行三层打分模型 +4. 保存打分结果并输出到 stdout + +### 3. 跑其他合约 + +```bash +# 螺纹钢 2510 合约(上期所) +docker-compose run --rm tushare python -m src.main RB2510.SHF + +# 铁矿石 2601 合约(大商所) +docker-compose run --rm tushare python -m src.main I2601.DCE +``` + +## 三层打分模型 + +### 综合分数公式 + +``` +综合分数 = (短期动力 × 0.4) + (中期趋势 × 0.35) + (长期结构 × 0.25) +``` + +### 1. 短期动力(7 日窗口,权重 0.4) + +逐日打分后取均值: + +| 持仓变化 | 价格方向 | 得分 | +|---------|---------|------| +| 增仓 | 上涨 | 100(多头主动进攻) | +| 增仓 | 下跌 | 0(空头主动进攻) | +| 减仓 | 上涨 | 70(空头撤退) | +| 减仓 | 下跌 | 30(多头撤退) | +| 持平(\|变化\|<1%) | 上涨 | 60 | +| 持平(\|变化\|<1%) | 下跌 | 40 | + +### 2. 中期趋势(15 日窗口,权重 0.35) + +``` +价格信号 = (今收 - 15日前收) / 15日前收 +价格信号得分 = clamp(50 + 收益率×500, 0, 100) + +资金意愿: + 增仓上涨天数 > 增仓下跌天数 → 80 + 两者相当 → 50 + 增仓下跌天数 > 增仓上涨天数 → 20 + +模块得分 = 价格信号 × 0.6 + 资金意愿 × 0.4 +``` + +### 3. 长期结构(30 日窗口,权重 0.25) + +``` +持仓变化幅度 = (30日日均持仓 - 30日前持仓) / 30日前持仓 + +> 10% → 90(显著增仓) +5%~10% → 70(温和增仓) +-5%~5% → 50(基本持平) +-10%~-5% → 30(温和减仓) +< -10% → 10(显著减仓) +``` + +### 信号解读 + +| 综合分数 | 信号 | +|---------|------| +| 80-100 | 强烈看多 — 价格与资金共振 | +| 50-80 | 偏多/震荡偏强 | +| 40-50 | 偏空/震荡偏弱 | +| 0-40 | 强烈看空 — 资金主动打压 | + +## 数据查询 + +SQLite 数据库位于 `data/futures.db`,可直接用 sqlite3 查询: + +```bash +# 查看最新打分 +sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scores ORDER BY trade_date DESC LIMIT 5;" + +# 查看合约日线 +sqlite3 data/futures.db "SELECT trade_date, open, high, low, close, vol, oi FROM candles WHERE ts_code='FG2609.ZCE' ORDER BY trade_date DESC LIMIT 10;" + +# 查看表结构 +sqlite3 data/futures.db ".schema" +``` + +## 项目结构 + +``` +trade/ +├── docker-compose.yml # Docker Compose 编排 +├── 使用说明.md # 本文件 +├── data/ # SQLite 数据库目录(gitignored) +│ └── futures.db +├── .gitignore # Git 忽略配置 +└── tushare/ # Python 数据服务 + ├── Dockerfile # 镜像构建 + ├── requirements.txt # Python 依赖 + ├── .env # TUSHARE_TOKEN(本地,不入库) + └── src/ # Python 包 + ├── models.py # 数据模型 + ├── fetcher.py # tushare 数据拉取 + ├── scorer.py # 打分模型核心 + ├── storage.py # SQLite 持久化 + └── main.py # CLI 入口 +``` + +## 技术栈 + +- **Python 3.13** (alpine 基础镜像) +- **tushare** — 中国金融数据接口 +- **pandas** — 数据处理 +- **SQLite** — 本地数据存储 +- **Docker / Docker Compose** — 容器化部署 + +## 常见问题 + +**Q: 为什么某些日期返回空数据?** + +A: tushare 数据更新有延迟,且不同接口对 token 积分等级有要求。若 `fut_daily` 返回空但 `trade_cal` 正常,通常是该日期实际行情数据尚未入库。 + +**Q: 合约代码格式?** + +A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大商所用 `.DCE`。注意不是 `.CZC`。 + +**Q: 如何定时自动跑?** + +A: 当前为手动 CLI 触发。后续可在 `docker-compose.yml` 中增加 cron 服务或接入调度器。 + +**Q: Go 后端怎么读数据?** + +A: Go 端可直接用 `database/sql` + `github.com/mattn/go-sqlite3` 读取 `data/futures.db` 中的 `candles` 和 `scores` 表。