diff --git a/.gitignore b/.gitignore
index 543d9c7..83f859f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,4 @@ logs/
# Virtual env
.venv/
venv/
+.gstack/
diff --git a/CLAUDE.md b/CLAUDE.md
index b8e6e25..2015c46 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,94 +1,92 @@
-# CLAUDE.md - A-Share Data Platform
+# CLAUDE.md — A-Share Data Platform (Trading OS)
## Project Overview
-A-share (Chinese stock market) data platform providing 9 K-line frequencies via Parquet + DuckDB storage with REST and WebSocket APIs. Greenfield project, currently at v0.1.0.
+A-share trading operating system built on Parquet + DuckDB with REST/WebSocket APIs. Architecture follows a 5-subsystem design: Data Platform → Market Intelligence → Signal Intelligence → Execution Intelligence → Presentation. Dashboard at `/dashboard` renders a Trading Command Center.
## Tech Stack
-- **Data source**: `akshare` (wraps East Money and Sina APIs)
-- **Storage**: Parquet (Zstd compression, Hive-partitioned `year=YYYY/month=MM/day=DD/`)
-- **Query engine**: DuckDB (embedded OLAP, `read_parquet` with `hive_partitioning=true, union_by_name=true`)
-- **Web**: FastAPI + uvicorn
-- **CLI**: Typer
-- **Scheduler**: APScheduler 3.x (AsyncIOScheduler)
-- **Config**: pydantic-settings (reads `.env`)
-- **Logging**: loguru
+- **Data**: `akshare` (East Money / Sina), Parquet (Zstd), DuckDB (analytics)
+- **API**: FastAPI + uvicorn, single `/api/v1/dashboard/state` endpoint + K-line/stock/calendar REST
+- **CLI**: Typer (`ashare-dp backfill daily|minute|industry|signals ...`)
+- **Scheduler**: APScheduler (EOD 15:05, EMA52 15:10)
+- **Config**: pydantic-settings (`.env`)
-## Project Structure
+## Project Structure (v10 — 5 Subsystems)
```
src/ashare_dp/
-├── config.py # Pydantic Settings (all config via env vars)
-├── core/
-│ ├── models.py # Freq enum with storage_dir property, freq groupings
-│ ├── calendar.py # Trading calendar, market state, Beijing TZ
-│ └── exceptions.py # AShareDPError hierarchy
-├── data/
-│ ├── akshare_client.py # AKShare wrapper: dual backend, retry, rate limit
-│ ├── backfill.py # Historical backfill (daily/weekly/monthly + minute)
-│ ├── eod.py # End-of-day batch pull (all stocks, all freqs)
-│ └── realtime.py # Background async spot poller → WebSocket broadcast
-├── storage/
-│ ├── database.py # DuckDB singleton (get_db)
-│ ├── schema.py # DDL: stock_info, trading_calendar
-│ ├── repository.py # KLineRepository: write_klines, read_klines, get_latest
-│ └── partitioning.py # Hive partition path builder
-├── api/
-│ ├── app.py # FastAPI factory + lifespan + embedded docs HTML
-│ ├── deps.py # FastAPI DI (get_repo)
-│ ├── routers/ # stocks, kline, realtime, calendar routers
-│ └── websocket/
-│ ├── manager.py # ConnectionManager: subscribe/broadcast with async lock
-│ └── handlers.py # WS message dispatch (/ws/realtime)
-├── scheduler/
-│ ├── scheduler.py # APScheduler setup (EOD 15:05, health check 08:00)
-│ └── jobs.py # Job implementations
-└── cli/
- ├── main.py # Typer root: ashare-dp {backfill, serve, query, version}
- ├── backfill_cmd.py
- ├── serve_cmd.py
- └── query_cmd.py
+├── config.py # Settings
+├── core/ # Shared kernel
+│ ├── models.py # Freq enum, INDEX_CODES, INDEX_SINA_SYMBOLS
+│ ├── codes.py # ★ Single ts_code conversion module (IDEMPOTENT)
+│ ├── calendar.py # Trading calendar, market state, Beijing TZ
+│ └── exceptions.py
+├── domain/ # Ontology — shared contracts
+│ ├── state.py # MarketState (7-dim continuous vector)
+│ ├── context.py # TradingContext, Playbook, Expectancy, Opportunity
+│ ├── events.py # RiskEvent
+│ ├── features.py # FeatureDefinition
+│ ├── leadership.py # LeaderState enum
+│ └── signal.py # SignalType, SignalInstance
+├── data/ # ═══ DATA PLATFORM ═══
+│ ├── sources/ # akshare_client, index, industry
+│ ├── pipelines/ # backfill, eod, realtime
+│ └── store/ # database (get_db, analytics_conn, kline_glob), repository, partitioning, schema
+├── features/ # Feature Store (6 registered features, all use analytics_conn + kline_glob)
+├── market/ # ═══ MARKET INTELLIGENCE ═══
+│ ├── state.py # infer_market_state
+│ ├── leadership.py # assess_leaders (lifecycle per industry)
+│ ├── opportunity.py # rank_opportunities
+│ ├── flow.py # compute_flow (money flow graph)
+│ ├── sentiment.py # Phase 2 placeholder
+│ └── memory.py # StateStore (state_snapshot table)
+├── signals/ # ═══ SIGNAL INTELLIGENCE ═══ (the moat)
+│ ├── detectors.py # EMA52 cross detection + shared screening logic
+│ ├── store.py # signal_instance CRUD (to be extracted from detectors)
+│ └── expectancy.py # get_expectancy(state) — single entry, fallback chain internal
+├── execution/ # ═══ EXECUTION INTELLIGENCE ═══
+│ ├── playbook.py # build_playbook (State → strategies/bias/holding)
+│ ├── risk.py # RiskRule engine + evaluate_risks
+│ └── brief.py # build_brief + brief_to_api_dict (single serialization point)
+└── apps/ # ═══ PRESENTATION ═══
+ ├── api/ # app.py, routers/, websocket/, dashboard/
+ ├── cli/ # main.py + backfill/serve/query/eod/screening commands
+ └── scheduler/ # scheduler.py, jobs.py
```
-## Critical Design Decisions
-
-### Freq.storage_dir property
-macOS APFS is case-insensitive, so `kline_1m` and `kline_1M` collide. The `Freq.M1` (monthly) uses `storage_dir = "1mon"` to disambiguate. Always use `freq.storage_dir` for filesystem paths, never `freq.value`.
-
-### Batched writes to avoid file overwrites
-`write_klines` groups all stocks for a day into a single `data.parquet` file. The backfill and EOD pipelines collect all DataFrames first, then write once per day/freq. Never write per-stock-per-day files.
-
-### Dual backend with auto-fallback
-`AKShareClient._resolve_backend()` probes East Money reachability once and caches the result. If unreachable (geo-blocked outside China), falls back to Sina. `get_stock_list()` and `get_trading_calendar()` are Sina-only.
-
-### SQL parameterization
-`read_klines()` and `get_latest()` use DuckDB parameterized queries (`$1`, `$2`) for user-supplied values (ts_code, dates). Do NOT use f-string interpolation for user input.
-
-### 2h derivation
-2-hour K-lines are derived on-the-fly from 1h data via `_read_2h()` using pandas resampling. No Parquet storage for 2h.
-
## Key Conventions
-- Stock codes: `ts_code` format is `"000001.SZ"` (6-digit code + exchange suffix). Internal API calls use 6-digit numeric strings.
-- Exchange mapping: codes starting with `6`/`9` → SH, `4`/`8`/`92` → BJ, rest → SZ
-- Backend-specific column normalization: Sina returns English columns (`date`, `open`, etc.), EM returns Chinese. `_normalize_hist_df` and `_normalize_min_df` handle both.
-- Proxy: env vars cleared + `urllib.request.getproxies` monkey-patched at module import time in `akshare_client.py`
-- All async state in `ConnectionManager` is protected by `asyncio.Lock`
+### ts_code conversion (CRITICAL)
+**Always use `from ashare_dp.core.codes import to_ts_code`** — the single idempotent implementation. Never write local `_code_to_ts_code()` copies. `to_ts_code()` is safe to call on any format: bare codes, already-formatted ts_codes, Sina symbols, even legacy corrupted `.SZ.SZ` values.
+
+### Database connections
+- **DuckDB tables** (stock_info, trading_calendar, signal_instance): use `get_db()` context manager from `data.store.database`
+- **Analytics queries** (features, engines): use `analytics_conn()` for raw DuckDB connection — the single sanctioned way. Never hardcode `"data/duckdb/ashare.db"`
+- **Parquet globs**: use `kline_glob()` or `partition_glob(freq)` — never hardcode paths
+
+### Architecture boundaries
+- `data/` knows nothing about trading
+- `features/` computes features, never classifies regimes
+- `market/` infers state, knows nothing about signals
+- `signals/` queries historical expectancy, knows nothing about execution
+- `execution/` maps state to strategies, assembles TradingBrief
+- `apps/` only renders, never reasons
+
+### MarketState is a continuous vector (not enum)
+7 dimensions: trend, fear, liquidity, rotation, participation, volatility, breadth. Each 0.0–1.0. Display labels derived downstream only.
+
+### API Response
+Single endpoint produces all dashboard data: `GET /api/v1/dashboard/state`. Response versioned (`"version": "1.0"`). Serialization in `execution/brief.py::brief_to_api_dict()` — the single serialization point. Router only orchestrates engine calls.
## Running
```bash
pip install -e ".[dev]"
-ashare-dp backfill init # First time: create tables, load stock list
-ashare-dp backfill daily # Backfill all daily/weekly/monthly history
-ashare-dp serve start # Start API + scheduler + realtime poller
-ashare-dp query stats # Check data status
-```
-
-## Tests
-
-```bash
-pytest
-ruff check src/
+ashare-dp backfill init # Schema + stock list + trading calendar
+ashare-dp backfill daily # Daily/weekly/monthly + indices
+ashare-dp backfill industry # Industry classifications
+ashare-dp backfill signals # EMA52 signal detection + store
+ashare-dp serve start # API + scheduler + realtime
+open http://localhost:8000/dashboard
```
diff --git a/pyproject.toml b/pyproject.toml
index a38bc4c..6ed3f3e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,7 +28,7 @@ dev = [
]
[project.scripts]
-ashare-dp = "ashare_dp.cli.main:app"
+ashare-dp = "ashare_dp.apps.cli.main:app"
[build-system]
requires = ["setuptools>=75.0"]
diff --git a/src/ashare_dp/apps/__init__.py b/src/ashare_dp/apps/__init__.py
new file mode 100644
index 0000000..67848f1
--- /dev/null
+++ b/src/ashare_dp/apps/__init__.py
@@ -0,0 +1 @@
+"""PRESENTATION — API, CLI, scheduler. Renders, never reasons."""
diff --git a/src/ashare_dp/api/__init__.py b/src/ashare_dp/apps/api/__init__.py
similarity index 100%
rename from src/ashare_dp/api/__init__.py
rename to src/ashare_dp/apps/api/__init__.py
diff --git a/src/ashare_dp/api/app.py b/src/ashare_dp/apps/api/app.py
similarity index 97%
rename from src/ashare_dp/api/app.py
rename to src/ashare_dp/apps/api/app.py
index 9352692..7fb491e 100644
--- a/src/ashare_dp/api/app.py
+++ b/src/ashare_dp/apps/api/app.py
@@ -10,12 +10,14 @@ from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
from loguru import logger
-from ashare_dp.api.routers import stocks, kline, realtime, calendar
+from ashare_dp.apps.api.routers import stocks, kline, realtime, calendar
+from ashare_dp.apps.api.dashboard.router import router as dashboard_router
+from ashare_dp.apps.api.dashboard.html import DASHBOARD_HTML
from ashare_dp.core.models import Freq
-from ashare_dp.storage.repository import KLineRepository
-from ashare_dp.api.websocket.handlers import router as ws_router
-from ashare_dp.storage.database import get_db
-from ashare_dp.storage.schema import DDL_STATEMENTS
+from ashare_dp.data.store.repository import KLineRepository
+from ashare_dp.apps.api.websocket.handlers import router as ws_router
+from ashare_dp.data.store.database import get_db
+from ashare_dp.data.store.schema import DDL_STATEMENTS
DOCS_HTML = r"""
@@ -425,7 +427,7 @@ async def lifespan(app: FastAPI):
# Start realtime poller
try:
- from ashare_dp.data.realtime import poller
+ from ashare_dp.data.pipelines.realtime import poller
await poller.start()
app.state.poller = poller
logger.info("Realtime poller started")
@@ -565,6 +567,11 @@ def create_app() -> FastAPI:
"""EMA52 screening results page."""
return EMA52_HTML
+ @app.get("/dashboard", response_class=HTMLResponse)
+ async def dashboard_page():
+ """Trading OS Dashboard — professional trader decision support."""
+ return DASHBOARD_HTML
+
@app.get("/api/v1/screening/ema52")
async def api_ema52_screening(
freq: str = Query("1d", description="Frequency: 1d or 1w"),
@@ -609,6 +616,7 @@ def create_app() -> FastAPI:
app.include_router(kline.router, prefix="/api/v1")
app.include_router(realtime.router, prefix="/api/v1")
app.include_router(calendar.router, prefix="/api/v1")
+ app.include_router(dashboard_router, prefix="/api/v1")
app.include_router(ws_router)
return app
diff --git a/src/ashare_dp/apps/api/dashboard/__init__.py b/src/ashare_dp/apps/api/dashboard/__init__.py
new file mode 100644
index 0000000..ee01ed1
--- /dev/null
+++ b/src/ashare_dp/apps/api/dashboard/__init__.py
@@ -0,0 +1 @@
+"""Dashboard package."""
diff --git a/src/ashare_dp/apps/api/dashboard/html.py b/src/ashare_dp/apps/api/dashboard/html.py
new file mode 100644
index 0000000..df2cdbf
--- /dev/null
+++ b/src/ashare_dp/apps/api/dashboard/html.py
@@ -0,0 +1,359 @@
+"""Dashboard HTML — Trading Command Center.
+
+5 sections: COMMAND → ACTION → WHERE → WHY → RISK → EXPECTANCY
+Everything else collapsed as Diagnostics.
+"""
+
+DASHBOARD_HTML = """
+
+
+
+Trading OS
+
+
+
+
+
+
+
+"""
diff --git a/src/ashare_dp/apps/api/dashboard/router.py b/src/ashare_dp/apps/api/dashboard/router.py
new file mode 100644
index 0000000..09bde6f
--- /dev/null
+++ b/src/ashare_dp/apps/api/dashboard/router.py
@@ -0,0 +1,235 @@
+"""Dashboard REST API — thin orchestrator.
+
+Calls engines → assembles TradingBrief → serializes via brief_to_api_dict.
+No inference, no manual JSON building.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from fastapi import APIRouter, HTTPException
+from loguru import logger
+
+from ashare_dp.core.calendar import BEIJING_TZ
+from ashare_dp.data.store.database import get_db, analytics_conn, kline_glob
+from ashare_dp.domain.context import Allocation
+from ashare_dp.execution.brief import build_brief, brief_to_api_dict, generate_narrative
+from ashare_dp.execution.playbook import build_playbook
+from ashare_dp.execution.risk import evaluate_risks
+from ashare_dp.features import registry
+from ashare_dp.market.flow import compute_flow
+from ashare_dp.market.leadership import assess_leaders
+from ashare_dp.market.memory import state_store
+from ashare_dp.market.opportunity import rank_opportunities
+from ashare_dp.market.knowledge import activate_themes
+from ashare_dp.market.recommendations import get_recommendations
+from ashare_dp.market.sentiment import assess_sentiment
+from ashare_dp.market.state import infer_market_state
+from ashare_dp.signals.expectancy import get_expectancy, get_all_expectancies
+
+router = APIRouter(tags=["dashboard"])
+
+
+def _get_latest_trade_date() -> date:
+ conn = analytics_conn()
+ try:
+ row = conn.execute(
+ f"SELECT MAX(trade_date) FROM read_parquet('{kline_glob()}', "
+ f"hive_partitioning=true, union_by_name=true)"
+ ).fetchone()
+ if row and row[0]:
+ return date.fromisoformat(str(row[0])[:10])
+ raise HTTPException(status_code=503, detail="No K-line data available")
+ finally:
+ conn.close()
+
+
+@router.get("/dashboard/state")
+async def dashboard_state():
+ """Complete market state — single atomic endpoint, versioned schema."""
+ trade_date = _get_latest_trade_date()
+ now = datetime.now(BEIJING_TZ)
+
+ # ── L1: Market Understanding ──
+ try:
+ features = registry.compute_all(None, trade_date)
+ except Exception as e:
+ logger.error(f"Feature computation failed: {e}")
+ raise HTTPException(status_code=500, detail=f"Feature computation: {e}")
+
+ state = infer_market_state(features, trade_date, timestamp=now)
+
+ # ── L2: Market Interpretation (each engine independent, failures degrade) ──
+ leaders, opportunities, money_flow = {}, [], None
+ try:
+ leaders = assess_leaders(trade_date)
+ except Exception as e:
+ logger.warning(f"Leadership engine failed: {e}")
+ try:
+ opportunities = rank_opportunities(trade_date)
+ except Exception as e:
+ logger.warning(f"Opportunity engine failed: {e}")
+ try:
+ money_flow = compute_flow(trade_date)
+ except Exception as e:
+ logger.warning(f"Flow engine failed: {e}")
+ sentiment = assess_sentiment(trade_date)
+
+ # Knowledge Graph — theme activation
+ theme_graph = activate_themes(trade_date, leaders=leaders, opportunities=opportunities)
+
+ # Stock Recommendations
+ try:
+ recommendations = get_recommendations(trade_date, top_n=10)
+ except Exception as e:
+ logger.warning(f"Recommendations failed: {e}")
+ recommendations = []
+
+ # ── L3: Trading Intelligence ──
+ playbook = build_playbook(state, leaders, opportunities)
+ expectancy = get_expectancy(state) # fallback chain lives inside
+ all_expectancies = get_all_expectancies(state)
+
+ allocation = Allocation(
+ cash_pct=round(max(state.fear * 0.6, 0.1), 2),
+ trend_pct=round(max(state.trend * 0.6, 0.1), 2),
+ trial_pct=0.2,
+ reasoning="恐惧偏高,保留现金缓冲" if state.fear > 0.5 else "趋势明确,积极参与",
+ )
+ narrative = generate_narrative(state, leaders, opportunities, money_flow)
+
+ brief = build_brief(
+ trade_date=trade_date, state=state, playbook=playbook,
+ expectancy=expectancy, leaders=leaders, opportunities=opportunities,
+ money_flow=money_flow, allocation=allocation, narrative=narrative,
+ timestamp=now,
+ )
+
+ try:
+ risk_events = evaluate_risks(features, timestamp=now)
+ except Exception as e:
+ logger.warning(f"Risk evaluation failed: {e}")
+ risk_events = []
+
+ # Persist state snapshot for Market Memory
+ try:
+ with get_db(read_only=False) as db:
+ state_store.save(db, "market", "1d", now, state)
+ except Exception as e:
+ logger.warning(f"StateStore save failed: {e}")
+
+ return brief_to_api_dict(brief, risk_events, features, sentiment, all_expectancies, theme_graph, recommendations)
+
+
+@router.post("/dashboard/trades")
+async def record_trade(request: dict):
+ """Record a trade for Trading Memory."""
+ if not request.get("ts_code") or not request.get("entry_price"):
+ raise HTTPException(status_code=400, detail="ts_code and entry_price are required")
+ from ashare_dp.data.store.database import get_db
+ with get_db(read_only=False) as db:
+ db.execute(
+ """INSERT INTO trade_log (ts_code, trade_date, entry_price, signal_type,
+ position_pct, state_trend, state_fear, state_liquidity, state_breadth,
+ tags, notes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ (request.get("ts_code"), request.get("trade_date"),
+ request.get("entry_price"), request.get("signal_type", ""),
+ request.get("position_pct", 0), request.get("state_trend", 0),
+ request.get("state_fear", 0), request.get("state_liquidity", 0),
+ request.get("state_breadth", 0), request.get("tags", ""),
+ request.get("notes", "")),
+ )
+ return {"status": "recorded"}
+
+
+@router.get("/dashboard/trades")
+async def list_trades(limit: int = 20, closed: bool | None = None):
+ """List recent trades with performance stats."""
+ from ashare_dp.data.store.database import get_db
+ with get_db(read_only=True) as db:
+ wheres = []
+ params = []
+ if closed is not None:
+ wheres.append("closed = ?")
+ params.append(closed)
+ where = ("WHERE " + " AND ".join(wheres)) if wheres else ""
+ rows = db.query(
+ f"SELECT * FROM trade_log {where} ORDER BY created_at DESC LIMIT ?",
+ tuple(params) + (limit,),
+ )
+ # Stats
+ stats_rows = db.query(
+ "SELECT COUNT(*) as total, AVG(return_pct) as avg_ret, "
+ "AVG(CASE WHEN return_pct>0 THEN 1.0 ELSE 0.0 END) as win_rate "
+ "FROM trade_log WHERE closed=TRUE"
+ )
+ stats = {"total": 0, "avg_return": 0, "win_rate": 0}
+ if stats_rows and stats_rows[0][0] > 0:
+ stats = {"total": int(stats_rows[0][0]),
+ "avg_return": round(float(stats_rows[0][1] or 0), 2),
+ "win_rate": round(float(stats_rows[0][2] or 0), 4)}
+
+ trades = []
+ for r in rows:
+ trades.append({
+ "id": r[0], "ts_code": r[1], "trade_date": str(r[2]) if r[2] else "",
+ "entry_price": r[3], "signal_type": r[5],
+ "return_pct": r[10], "holding_days": r[11],
+ "closed": bool(r[20]), "tags": r[18], "notes": r[19],
+ })
+ return {"trades": trades, "stats": stats}
+
+
+@router.put("/dashboard/trades/{trade_id}/close")
+async def close_trade(trade_id: int, request: dict):
+ """Close a trade with exit price and reason."""
+ from ashare_dp.data.store.database import get_db
+ with get_db(read_only=False) as db:
+ db.execute(
+ """UPDATE trade_log SET exit_price=?, exit_reason=?, return_pct=?,
+ holding_days=?, closed=TRUE
+ WHERE id=?""",
+ (request.get("exit_price"), request.get("exit_reason", "manual"),
+ request.get("return_pct"), request.get("holding_days", 0), trade_id),
+ )
+ return {"status": "closed"}
+
+
+@router.get("/dashboard/replay")
+async def dashboard_replay(date_str: str = ""):
+ """Return all intraday state snapshots for a given date (YYYY-MM-DD)."""
+ from datetime import date as _date
+ if not date_str:
+ date_str = _get_latest_trade_date().isoformat()
+
+ try:
+ with get_db(read_only=True) as db:
+ rows = db.query(
+ "SELECT timestamp, state FROM state_snapshot "
+ "WHERE entity = 'market' AND timeframe = '30min' "
+ "AND CAST(timestamp AS DATE) = ? "
+ "ORDER BY timestamp ASC",
+ (date_str,),
+ )
+ except Exception:
+ rows = []
+
+ snapshots = []
+ for ts, state_json in rows:
+ try:
+ import json
+ state = json.loads(state_json)
+ snapshots.append({
+ "timestamp": ts.isoformat() if hasattr(ts, 'isoformat') else str(ts),
+ "dimensions": state.get("dimensions", {}),
+ })
+ except Exception:
+ pass
+
+ return {
+ "date": date_str,
+ "count": len(snapshots),
+ "snapshots": snapshots,
+ }
diff --git a/src/ashare_dp/api/deps.py b/src/ashare_dp/apps/api/deps.py
similarity index 71%
rename from src/ashare_dp/api/deps.py
rename to src/ashare_dp/apps/api/deps.py
index fcd87be..d9ef379 100644
--- a/src/ashare_dp/api/deps.py
+++ b/src/ashare_dp/apps/api/deps.py
@@ -1,6 +1,6 @@
"""FastAPI dependency injection."""
-from ashare_dp.storage.repository import KLineRepository
+from ashare_dp.data.store.repository import KLineRepository
def get_repo() -> KLineRepository:
diff --git a/src/ashare_dp/api/routers/__init__.py b/src/ashare_dp/apps/api/routers/__init__.py
similarity index 100%
rename from src/ashare_dp/api/routers/__init__.py
rename to src/ashare_dp/apps/api/routers/__init__.py
diff --git a/src/ashare_dp/api/routers/calendar.py b/src/ashare_dp/apps/api/routers/calendar.py
similarity index 100%
rename from src/ashare_dp/api/routers/calendar.py
rename to src/ashare_dp/apps/api/routers/calendar.py
diff --git a/src/ashare_dp/api/routers/kline.py b/src/ashare_dp/apps/api/routers/kline.py
similarity index 97%
rename from src/ashare_dp/api/routers/kline.py
rename to src/ashare_dp/apps/api/routers/kline.py
index 53f59c4..45941f9 100644
--- a/src/ashare_dp/api/routers/kline.py
+++ b/src/ashare_dp/apps/api/routers/kline.py
@@ -6,9 +6,9 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
-from ashare_dp.api.deps import get_repo
+from ashare_dp.apps.api.deps import get_repo
from ashare_dp.core.models import BACKFILLABLE_FREQS, DERIVED_FREQS, INTRADAY_FREQS, Freq
-from ashare_dp.storage.repository import KLineRepository
+from ashare_dp.data.store.repository import KLineRepository
router = APIRouter(prefix="/klines", tags=["klines"])
diff --git a/src/ashare_dp/api/routers/realtime.py b/src/ashare_dp/apps/api/routers/realtime.py
similarity index 87%
rename from src/ashare_dp/api/routers/realtime.py
rename to src/ashare_dp/apps/api/routers/realtime.py
index 40c5a05..33ac27a 100644
--- a/src/ashare_dp/api/routers/realtime.py
+++ b/src/ashare_dp/apps/api/routers/realtime.py
@@ -6,8 +6,9 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
+from ashare_dp.core.codes import to_ts_code
from ashare_dp.core.calendar import BEIJING_TZ, determine_market_state
-from ashare_dp.data.akshare_client import AKShareClient
+from ashare_dp.data.sources.akshare_client import AKShareClient
router = APIRouter(prefix="/realtime", tags=["realtime"])
@@ -45,14 +46,7 @@ async def get_spot(
# Build ts_code from code
if "code" in df.columns:
- def _to_ts_code(c):
- c = str(c).zfill(6)
- if c.startswith(("6", "9")):
- return f"{c}.SH"
- elif c.startswith(("8", "4")):
- return f"{c}.BJ"
- return f"{c}.SZ"
- df["ts_code"] = df["code"].apply(_to_ts_code)
+ df["ts_code"] = df["code"].apply(to_ts_code)
# Filter by requested codes
if codes:
diff --git a/src/ashare_dp/api/routers/stocks.py b/src/ashare_dp/apps/api/routers/stocks.py
similarity index 98%
rename from src/ashare_dp/api/routers/stocks.py
rename to src/ashare_dp/apps/api/routers/stocks.py
index 7b565dc..79f3b1d 100644
--- a/src/ashare_dp/api/routers/stocks.py
+++ b/src/ashare_dp/apps/api/routers/stocks.py
@@ -6,7 +6,7 @@ from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
-from ashare_dp.storage.database import get_db
+from ashare_dp.data.store.database import get_db
router = APIRouter(prefix="/stocks", tags=["stocks"])
diff --git a/src/ashare_dp/api/websocket/__init__.py b/src/ashare_dp/apps/api/websocket/__init__.py
similarity index 100%
rename from src/ashare_dp/api/websocket/__init__.py
rename to src/ashare_dp/apps/api/websocket/__init__.py
diff --git a/src/ashare_dp/api/websocket/handlers.py b/src/ashare_dp/apps/api/websocket/handlers.py
similarity index 97%
rename from src/ashare_dp/api/websocket/handlers.py
rename to src/ashare_dp/apps/api/websocket/handlers.py
index fa8cf29..2e4d0f3 100644
--- a/src/ashare_dp/api/websocket/handlers.py
+++ b/src/ashare_dp/apps/api/websocket/handlers.py
@@ -7,7 +7,7 @@ from datetime import datetime
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from loguru import logger
-from ashare_dp.api.websocket.manager import manager
+from ashare_dp.apps.api.websocket.manager import manager
from ashare_dp.core.calendar import BEIJING_TZ, determine_market_state
router = APIRouter()
diff --git a/src/ashare_dp/api/websocket/manager.py b/src/ashare_dp/apps/api/websocket/manager.py
similarity index 100%
rename from src/ashare_dp/api/websocket/manager.py
rename to src/ashare_dp/apps/api/websocket/manager.py
diff --git a/src/ashare_dp/cli/__init__.py b/src/ashare_dp/apps/cli/__init__.py
similarity index 100%
rename from src/ashare_dp/cli/__init__.py
rename to src/ashare_dp/apps/cli/__init__.py
diff --git a/src/ashare_dp/apps/cli/backfill_cmd.py b/src/ashare_dp/apps/cli/backfill_cmd.py
new file mode 100644
index 0000000..1dbab5a
--- /dev/null
+++ b/src/ashare_dp/apps/cli/backfill_cmd.py
@@ -0,0 +1,167 @@
+"""Backfill CLI subcommands."""
+
+from __future__ import annotations
+
+import asyncio
+from datetime import date, datetime
+
+import typer
+from loguru import logger
+
+from ashare_dp.core.models import BACKFILLABLE_FREQS, INTRADAY_FREQS
+from ashare_dp.data.sources.akshare_client import AKShareClient
+from ashare_dp.data.pipelines.backfill import BackfillPipeline
+from ashare_dp.data.sources.index import backfill_indices
+from ashare_dp.data.sources.industry import fetch_industry_mapping, store_industry_mapping
+
+backfill_app = typer.Typer()
+
+
+@backfill_app.command("init")
+def init_db():
+ """Initialize database schema."""
+ pipeline = BackfillPipeline()
+ pipeline.init_db()
+ pipeline.load_stock_list()
+ pipeline.load_trading_calendar()
+ typer.echo("Database initialized with stock list and trading calendar")
+
+
+@backfill_app.command("daily")
+def backfill_daily(
+ start: str = typer.Option("19900101", help="Start date YYYYMMDD"),
+ end: str = typer.Option(None, help="End date YYYYMMDD (default: today)"),
+ workers: int = typer.Option(10, help="Number of worker threads"),
+ symbols: str = typer.Option(None, help="Comma-separated stock symbols (default: all)"),
+ skip_index: bool = typer.Option(False, help="Skip index K-line backfill"),
+):
+ """Backfill daily/weekly/monthly K-line data."""
+
+ start_date = datetime.strptime(start, "%Y%m%d").date()
+ end_date = datetime.strptime(end, "%Y%m%d").date() if end else date.today()
+
+ sym_list = [s.strip() for s in symbols.split(",")] if symbols else None
+
+ pipeline = BackfillPipeline(max_workers=workers)
+ pipeline.init_db()
+ pipeline.load_stock_list()
+ pipeline.load_trading_calendar()
+
+ results = pipeline.backfill_daily_weekly_monthly(
+ symbols=sym_list,
+ start_date=start_date,
+ end_date=end_date,
+ )
+
+ typer.echo(f"\nBackfill complete:")
+ for freq, stats in results.items():
+ typer.echo(
+ f" {freq}: {stats['records']} records, "
+ f"{stats['completed']} stocks ok, {stats['failed']} failed"
+ )
+
+ # Index backfill
+ if not skip_index and sym_list is None:
+ typer.echo("\nBackfilling index data...")
+ idx_results = backfill_indices(
+ start_date=start_date.strftime("%Y%m%d"),
+ end_date=end_date.strftime("%Y%m%d"),
+ )
+ for ts_code, count in idx_results.items():
+ typer.echo(f" {ts_code}: {count} bars")
+ elif skip_index:
+ typer.echo("\nSkipping index backfill (--skip-index)")
+
+
+@backfill_app.command("minute")
+def backfill_minute(
+ days: int = typer.Option(30, help="Number of calendar days to look back"),
+ workers: int = typer.Option(5, help="Number of worker threads"),
+ symbols: str = typer.Option(None, help="Comma-separated stock symbols (default: all)"),
+):
+ """Backfill recent minute K-line data (limited API history)."""
+
+ sym_list = [s.strip() for s in symbols.split(",")] if symbols else None
+
+ pipeline = BackfillPipeline(max_workers=workers)
+ pipeline.init_db()
+ pipeline.load_stock_list()
+
+ results = pipeline.backfill_minute(
+ symbols=sym_list,
+ days_back=days,
+ )
+
+ typer.echo(f"\nMinute backfill complete ({days} day lookback):")
+ for freq, stats in results.items():
+ typer.echo(
+ f" {freq}: {stats['records']} records, "
+ f"{stats['completed']} stocks ok, {stats['failed']} failed"
+ )
+
+
+@backfill_app.command("industry")
+def backfill_industry():
+ """Fetch and store industry classifications for all stocks."""
+ from ashare_dp.data.store.database import get_db
+ from ashare_dp.data.store.schema import DDL_STATEMENTS
+
+ typer.echo("Ensuring schema...")
+ with get_db(read_only=False) as db:
+ for ddl in DDL_STATEMENTS:
+ try:
+ db.execute(ddl)
+ except Exception:
+ pass # Table may already exist
+
+ typer.echo("Fetching industry classifications...")
+ df = fetch_industry_mapping()
+ count = store_industry_mapping(df)
+ typer.echo(f"Industry data stored: {count} stocks classified")
+
+
+@backfill_app.command("signals")
+def backfill_signals(
+ days: int = typer.Option(500, help="Calendar days to scan for historical signals"),
+):
+ """Detect and store historical trading signals (all types)."""
+ from ashare_dp.data.store.database import get_db
+ from ashare_dp.data.store.schema import DDL_STATEMENTS
+ from ashare_dp.signals.detectors import (
+ detect_ema52_signals, detect_vegas_signals,
+ detect_chan_signals, detect_orb_signals,
+ detect_gap_signals, detect_nr7_signals, detect_ib_signals,
+ )
+ from ashare_dp.signals.store import store_signals
+
+ typer.echo("Ensuring schema...")
+ with get_db(read_only=False) as db:
+ for ddl in DDL_STATEMENTS:
+ try:
+ db.execute(ddl)
+ except Exception:
+ pass
+
+ detectors = [
+ ("EMA52", detect_ema52_signals),
+ ("Vegas", detect_vegas_signals),
+ ("Chan", detect_chan_signals),
+ ("ORB", detect_orb_signals),
+ ("Gap", detect_gap_signals),
+ ("NR7", detect_nr7_signals),
+ ("IB", detect_ib_signals),
+ ]
+
+ total = 0
+ for name, detector in detectors:
+ typer.echo(f"Scanning {days} days for {name} signals...")
+ df = detector(lookback_days=days)
+ if df.empty:
+ typer.echo(f" {name}: 0 signals")
+ continue
+ count = store_signals(df)
+ total += count
+ by_type = df["signal_type"].value_counts().to_dict()
+ typer.echo(f" {name}: {count} stored {dict(by_type)}")
+
+ typer.echo(f"Total signals stored: {total}")
diff --git a/src/ashare_dp/cli/eod_cmd.py b/src/ashare_dp/apps/cli/eod_cmd.py
similarity index 96%
rename from src/ashare_dp/cli/eod_cmd.py
rename to src/ashare_dp/apps/cli/eod_cmd.py
index a417e11..b1bff48 100644
--- a/src/ashare_dp/cli/eod_cmd.py
+++ b/src/ashare_dp/apps/cli/eod_cmd.py
@@ -8,7 +8,7 @@ import typer
from loguru import logger
from ashare_dp.core.calendar import BEIJING_TZ
-from ashare_dp.data.eod import EODPipeline
+from ashare_dp.data.pipelines.eod import EODPipeline
eod_app = typer.Typer()
diff --git a/src/ashare_dp/cli/main.py b/src/ashare_dp/apps/cli/main.py
similarity index 72%
rename from src/ashare_dp/cli/main.py
rename to src/ashare_dp/apps/cli/main.py
index 46b6e71..f549fbf 100644
--- a/src/ashare_dp/cli/main.py
+++ b/src/ashare_dp/apps/cli/main.py
@@ -4,11 +4,11 @@ from __future__ import annotations
import typer
-from ashare_dp.cli.backfill_cmd import backfill_app
-from ashare_dp.cli.serve_cmd import serve_app
-from ashare_dp.cli.query_cmd import query_app
-from ashare_dp.cli.eod_cmd import eod_app
-from ashare_dp.cli.screening_cmd import screening_app
+from ashare_dp.apps.cli.backfill_cmd import backfill_app
+from ashare_dp.apps.cli.serve_cmd import serve_app
+from ashare_dp.apps.cli.query_cmd import query_app
+from ashare_dp.apps.cli.eod_cmd import eod_app
+from ashare_dp.apps.cli.screening_cmd import screening_app
app = typer.Typer(
name="ashare-dp",
diff --git a/src/ashare_dp/cli/query_cmd.py b/src/ashare_dp/apps/cli/query_cmd.py
similarity index 96%
rename from src/ashare_dp/cli/query_cmd.py
rename to src/ashare_dp/apps/cli/query_cmd.py
index 85d3463..daddf65 100644
--- a/src/ashare_dp/cli/query_cmd.py
+++ b/src/ashare_dp/apps/cli/query_cmd.py
@@ -7,8 +7,8 @@ from datetime import date, datetime
import typer
from ashare_dp.core.models import Freq
-from ashare_dp.storage.database import get_db
-from ashare_dp.storage.repository import KLineRepository
+from ashare_dp.data.store.database import get_db
+from ashare_dp.data.store.repository import KLineRepository
query_app = typer.Typer()
diff --git a/src/ashare_dp/apps/cli/screening_cmd.py b/src/ashare_dp/apps/cli/screening_cmd.py
new file mode 100644
index 0000000..44ed511
--- /dev/null
+++ b/src/ashare_dp/apps/cli/screening_cmd.py
@@ -0,0 +1,23 @@
+"""Screening CLI subcommand — EMA52 scanning and other screens."""
+
+from __future__ import annotations
+
+import typer
+
+screening_app = typer.Typer()
+
+
+@screening_app.command("ema52")
+def ema52(
+ threshold: float = typer.Option(
+ None, "--threshold", "-t",
+ help="EMA52 proximity threshold (e.g. 0.03 = ±3%%). Default from .env",
+ ),
+):
+ """Run EMA52 screening for 1d and 1w."""
+ from ashare_dp.signals.detectors import run_ema52_screening
+
+ results = run_ema52_screening(threshold=threshold)
+ for freq, count in results.items():
+ typer.echo(f" {freq}: {count} stocks near EMA52")
+ typer.echo("EMA52 screening complete")
diff --git a/src/ashare_dp/cli/serve_cmd.py b/src/ashare_dp/apps/cli/serve_cmd.py
similarity index 95%
rename from src/ashare_dp/cli/serve_cmd.py
rename to src/ashare_dp/apps/cli/serve_cmd.py
index 8bc1797..499c2a9 100644
--- a/src/ashare_dp/cli/serve_cmd.py
+++ b/src/ashare_dp/apps/cli/serve_cmd.py
@@ -30,7 +30,7 @@ def serve(
# The FastAPI lifespan handles DB init, scheduler start, and poller start
uvicorn.run(
- "ashare_dp.api.app:create_app",
+ "ashare_dp.apps.api.app:create_app",
host=h,
port=p,
reload=reload,
diff --git a/src/ashare_dp/scheduler/__init__.py b/src/ashare_dp/apps/scheduler/__init__.py
similarity index 100%
rename from src/ashare_dp/scheduler/__init__.py
rename to src/ashare_dp/apps/scheduler/__init__.py
diff --git a/src/ashare_dp/apps/scheduler/jobs.py b/src/ashare_dp/apps/scheduler/jobs.py
new file mode 100644
index 0000000..c606852
--- /dev/null
+++ b/src/ashare_dp/apps/scheduler/jobs.py
@@ -0,0 +1,68 @@
+"""Scheduler job implementations."""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from loguru import logger
+
+from ashare_dp.core.calendar import BEIJING_TZ
+from ashare_dp.core.models import Freq
+from ashare_dp.data.pipelines.eod import EODPipeline
+from ashare_dp.data.store.repository import KLineRepository
+
+
+async def eod_pull_job():
+ """End-of-day data pull. Triggered at 15:05 Beijing time on trading days."""
+ now = datetime.now(BEIJING_TZ)
+ today = now.date()
+
+ if now.weekday() >= 5:
+ logger.info("EOD: Weekend, skipping")
+ return
+
+ logger.info(f"EOD job starting for {today.isoformat()}...")
+ pipeline = EODPipeline()
+
+ try:
+ symbols = pipeline.get_active_symbols()
+ logger.info(f"EOD: {len(symbols)} active stocks")
+ except Exception as e:
+ logger.error(f"EOD: Failed to get stock list: {e}")
+ return
+
+ try:
+ pipeline.pull_daily(symbols, today)
+ except Exception as e:
+ logger.error(f"EOD daily pull failed: {e}")
+
+ try:
+ pipeline.pull_minute(symbols, today)
+ except Exception as e:
+ logger.error(f"EOD minute pull failed: {e}")
+
+ try:
+ pipeline.pull_weekly_monthly(symbols, today)
+ except Exception as e:
+ logger.error(f"EOD weekly/monthly pull failed: {e}")
+
+ logger.info(f"EOD job completed for {today.isoformat()}")
+
+
+async def health_check_job():
+ """Daily health check: report database statistics."""
+ logger.info("Health check running...")
+ repo = KLineRepository()
+ try:
+ logger.info(
+ f"Health: 1d={repo.count_records(Freq.d1)}, "
+ f"1h={repo.count_records(Freq.h1)}"
+ )
+ except Exception as e:
+ logger.error(f"Health check failed: {e}")
+
+
+async def ema52_screening_job():
+ """EOD EMA52 screening — delegates to shared implementation."""
+ from ashare_dp.signals.detectors import run_ema52_screening
+ run_ema52_screening()
diff --git a/src/ashare_dp/scheduler/scheduler.py b/src/ashare_dp/apps/scheduler/scheduler.py
similarity index 92%
rename from src/ashare_dp/scheduler/scheduler.py
rename to src/ashare_dp/apps/scheduler/scheduler.py
index 834b4e4..7e2bc0d 100644
--- a/src/ashare_dp/scheduler/scheduler.py
+++ b/src/ashare_dp/apps/scheduler/scheduler.py
@@ -7,7 +7,7 @@ from apscheduler.triggers.cron import CronTrigger
from loguru import logger
from ashare_dp.core.calendar import BEIJING_TZ
-from ashare_dp.data.akshare_client import AKShareClient
+from ashare_dp.data.sources.akshare_client import AKShareClient
class Scheduler:
@@ -19,7 +19,7 @@ class Scheduler:
def start(self):
"""Start the scheduler and register jobs."""
- from ashare_dp.scheduler.jobs import eod_pull_job, ema52_screening_job, health_check_job
+ from ashare_dp.apps.scheduler.jobs import eod_pull_job, ema52_screening_job, health_check_job
# EOD job: 15:05 Beijing time, Mon-Fri
self._scheduler.add_job(
diff --git a/src/ashare_dp/cli/backfill_cmd.py b/src/ashare_dp/cli/backfill_cmd.py
deleted file mode 100644
index 1ffd01a..0000000
--- a/src/ashare_dp/cli/backfill_cmd.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""Backfill CLI subcommands."""
-
-from __future__ import annotations
-
-import asyncio
-from datetime import date, datetime
-
-import typer
-from loguru import logger
-
-from ashare_dp.core.models import BACKFILLABLE_FREQS, INTRADAY_FREQS
-from ashare_dp.data.akshare_client import AKShareClient
-from ashare_dp.data.backfill import BackfillPipeline
-
-backfill_app = typer.Typer()
-
-
-@backfill_app.command("init")
-def init_db():
- """Initialize database schema."""
- pipeline = BackfillPipeline()
- pipeline.init_db()
- pipeline.load_stock_list()
- pipeline.load_trading_calendar()
- typer.echo("Database initialized with stock list and trading calendar")
-
-
-@backfill_app.command("daily")
-def backfill_daily(
- start: str = typer.Option("19900101", help="Start date YYYYMMDD"),
- end: str = typer.Option(None, help="End date YYYYMMDD (default: today)"),
- workers: int = typer.Option(10, help="Number of worker threads"),
- symbols: str = typer.Option(None, help="Comma-separated stock symbols (default: all)"),
-):
- """Backfill daily/weekly/monthly K-line data."""
-
- start_date = datetime.strptime(start, "%Y%m%d").date()
- end_date = datetime.strptime(end, "%Y%m%d").date() if end else date.today()
-
- sym_list = [s.strip() for s in symbols.split(",")] if symbols else None
-
- pipeline = BackfillPipeline(max_workers=workers)
- pipeline.init_db()
- pipeline.load_stock_list()
- pipeline.load_trading_calendar()
-
- results = pipeline.backfill_daily_weekly_monthly(
- symbols=sym_list,
- start_date=start_date,
- end_date=end_date,
- )
-
- typer.echo(f"\nBackfill complete:")
- for freq, stats in results.items():
- typer.echo(
- f" {freq}: {stats['records']} records, "
- f"{stats['completed']} stocks ok, {stats['failed']} failed"
- )
-
-
-@backfill_app.command("minute")
-def backfill_minute(
- days: int = typer.Option(30, help="Number of calendar days to look back"),
- workers: int = typer.Option(5, help="Number of worker threads"),
- symbols: str = typer.Option(None, help="Comma-separated stock symbols (default: all)"),
-):
- """Backfill recent minute K-line data (limited API history)."""
-
- sym_list = [s.strip() for s in symbols.split(",")] if symbols else None
-
- pipeline = BackfillPipeline(max_workers=workers)
- pipeline.init_db()
- pipeline.load_stock_list()
-
- results = pipeline.backfill_minute(
- symbols=sym_list,
- days_back=days,
- )
-
- typer.echo(f"\nMinute backfill complete ({days} day lookback):")
- for freq, stats in results.items():
- typer.echo(
- f" {freq}: {stats['records']} records, "
- f"{stats['completed']} stocks ok, {stats['failed']} failed"
- )
diff --git a/src/ashare_dp/cli/screening_cmd.py b/src/ashare_dp/cli/screening_cmd.py
deleted file mode 100644
index 34d8084..0000000
--- a/src/ashare_dp/cli/screening_cmd.py
+++ /dev/null
@@ -1,168 +0,0 @@
-"""Screening CLI subcommand: EMA52 scanning and other screens."""
-
-from __future__ import annotations
-
-import typer
-from loguru import logger
-
-from ashare_dp.storage.database import get_db
-
-screening_app = typer.Typer()
-
-
-@screening_app.command("ema52")
-def ema52(
- threshold: float = typer.Option(
- None, "--threshold", "-t",
- help="EMA52 proximity threshold (e.g. 0.03 = ±3%%). Default from .env",
- ),
-):
- """Run EMA52 screening for 1d and 1w.
-
- Scans all stocks, computes EMA52 from close prices,
- and saves results near EMA52 to the ema52_screening table.
- """
- import numpy as np
- import pandas as pd
- from ashare_dp.config import Settings
- from ashare_dp.core.models import Freq
-
- settings = Settings()
- if threshold is None:
- threshold = settings.ema52_threshold
-
- with get_db(read_only=False) as db:
- # Get all ts_codes and names
- stocks = db.query(
- "SELECT ts_code, name FROM stock_info ORDER BY ts_code"
- )
- symbols = [row[0] for row in stocks]
- symbol_names = {row[0]: row[1] for row in stocks}
-
- logger.info(
- f"EMA52 screening: {len(symbols)} stocks, "
- f"threshold=±{threshold * 100:.1f}%"
- )
-
- for freq, freq_label, bars_needed in [
- (Freq.d1, "1d", 100),
- (Freq.w1, "1w", 60),
- ]:
- try:
- from ashare_dp.storage.partitioning import partition_glob
- glob = partition_glob(freq)
- sql = f"""
- WITH ranked AS (
- SELECT *, ROW_NUMBER() OVER (
- PARTITION BY ts_code ORDER BY trade_time DESC
- ) as rn
- FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true)
- )
- SELECT * FROM ranked WHERE rn <= {bars_needed}
- ORDER BY ts_code, trade_time ASC
- """
- df = _query_parquet(sql)
- if df.empty:
- logger.warning(f"EMA52: no data for {freq_label}")
- continue
- except Exception as e:
- logger.error(f"EMA52: failed to read {freq_label}: {e}")
- continue
-
- df["trade_time"] = pd.to_datetime(df["trade_time"])
- df = df.sort_values(["ts_code", "trade_time"])
-
- results = []
- for ts_code, group in df.groupby("ts_code"):
- if ts_code not in symbol_names:
- continue
- group = group.tail(bars_needed)
- if len(group) < 26:
- continue
-
- closes = group["close"].astype(float).values
- ema_values = _compute_ema(closes, 52)
- if len(ema_values) == 0:
- continue
-
- latest_close = closes[-1]
- latest_ema = ema_values[-1]
- if latest_ema <= 0:
- continue
- distance = (latest_close - latest_ema) / latest_ema
-
- # Compute daily amount in 亿 CNY
- latest_amount = float(group["amount"].iloc[-1])
- amount_yi = latest_amount / 100_000_000 # 元 → 亿
-
- if abs(distance) <= threshold:
- results.append({
- "trade_date": group["trade_date"].iloc[-1],
- "ts_code": ts_code,
- "name": symbol_names[ts_code],
- "freq": freq_label,
- "close_price": round(latest_close, 2),
- "ema52": round(latest_ema, 2),
- "distance_pct": round(distance * 100, 2),
- "amount": round(amount_yi, 2),
- })
-
- if results:
- # Sort by amount DESC (largest turnover first)
- results.sort(key=lambda r: r.get("amount", 0) or 0, reverse=True)
- try:
- with get_db(read_only=False) as wdb:
- wdb.conn.execute(
- "DELETE FROM ema52_screening WHERE freq = ?",
- [freq_label],
- )
- wdb.conn.executemany(
- """INSERT INTO ema52_screening
- (trade_date, ts_code, name, freq, close_price, ema52, distance_pct, amount)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT (trade_date, ts_code, freq) DO UPDATE SET
- close_price=excluded.close_price,
- ema52=excluded.ema52,
- distance_pct=excluded.distance_pct,
- amount=excluded.amount,
- name=excluded.name,
- updated_at=now()""",
- [(r["trade_date"], r["ts_code"], r["name"],
- r["freq"], r["close_price"], r["ema52"], r["distance_pct"], r["amount"])
- for r in results],
- )
- except Exception as e:
- logger.error(f"EMA52: failed to save {freq_label}: {e}")
- continue
-
- logger.info(
- f"EMA52 {freq_label}: {len(results)} near "
- f"(±{threshold * 100:.1f}%)"
- )
-
- logger.info("EMA52 screening complete")
-
-
-
-def _query_parquet(sql: str) -> "pd.DataFrame":
- """Execute a SQL query against Parquet files using a transient in-memory DuckDB."""
- import duckdb
- import pandas as pd
- conn = duckdb.connect()
- try:
- return conn.execute(sql).fetchdf()
- finally:
- conn.close()
-
-
-def _compute_ema(values: "np.ndarray", period: int) -> "np.ndarray":
- """Compute EMA for a 1D array of values."""
- import numpy as np
- if len(values) < period:
- return np.array([])
- alpha = 2.0 / (period + 1)
- ema = np.zeros(len(values))
- ema[period - 1] = np.mean(values[:period])
- for i in range(period, len(values)):
- ema[i] = alpha * values[i] + (1 - alpha) * ema[i - 1]
- return ema
diff --git a/src/ashare_dp/core/codes.py b/src/ashare_dp/core/codes.py
new file mode 100644
index 0000000..a3768ec
--- /dev/null
+++ b/src/ashare_dp/core/codes.py
@@ -0,0 +1,134 @@
+"""ts_code conversion — the single implementation for the entire platform.
+
+All code ↔ ts_code ↔ sina_symbol conversions live here.
+Every function is IDEMPOTENT: feeding an already-converted value
+returns it unchanged (this prevents the double-suffix corruption
+that previously produced values like "000001.SZ.SZ").
+
+Formats:
+- bare code: "000001" (6-digit numeric string)
+- ts_code: "000001.SZ" (code + exchange suffix)
+- sina symbol: "sz000001" (lowercase exchange prefix + code)
+"""
+
+from __future__ import annotations
+
+import re
+
+_TS_CODE_RE = re.compile(r"^(\d{6})\.(SH|SZ|BJ)$")
+_SINA_RE = re.compile(r"^(sh|sz|bj)(\d{6})$")
+
+VALID_SUFFIXES = (".SH", ".SZ", ".BJ")
+
+
+def exchange_for_code(code: str) -> str:
+ """Determine exchange suffix from a bare 6-digit code.
+
+ Rules:
+ - 4xxxxx, 8xxxxx, 92xxxx → BJ (Beijing)
+ - 6xxxxx, 9xxxxx → SH (Shanghai)
+ - everything else → SZ (Shenzhen)
+ """
+ code = str(code).strip()
+ if code.startswith(("4", "8")) or code.startswith("92"):
+ return "BJ"
+ if code.startswith(("6", "9")):
+ return "SH"
+ return "SZ"
+
+
+def to_ts_code(value: str) -> str:
+ """Convert any supported format to ts_code. IDEMPOTENT.
+
+ "000001" → "000001.SZ"
+ "000001.SZ" → "000001.SZ" (unchanged — never double-suffixes)
+ "sz000001" → "000001.SZ"
+ "000001.SZ.SZ" → "000001.SZ" (repairs legacy corruption)
+ """
+ value = str(value).strip()
+
+ # Repair legacy double suffix first
+ value = normalize_ts_code(value)
+
+ # Already a valid ts_code?
+ if _TS_CODE_RE.match(value.upper()):
+ return value.upper()
+
+ # Sina symbol format?
+ m = _SINA_RE.match(value.lower())
+ if m:
+ prefix, code = m.groups()
+ return f"{code}.{prefix.upper()}"
+
+ # Bare numeric code
+ if value.isdigit():
+ code = value.zfill(6)
+ return f"{code}.{exchange_for_code(code)}"
+
+ # Unknown format — return as-is (caller's problem)
+ return value
+
+
+def normalize_ts_code(ts_code: str) -> str:
+ """Strip duplicated exchange suffixes. IDEMPOTENT.
+
+ "000001.SZ.SZ" → "000001.SZ"
+ "000001.SZ" → "000001.SZ"
+ """
+ ts_code = str(ts_code).strip()
+ upper = ts_code.upper()
+ for suffix in VALID_SUFFIXES:
+ doubled = suffix + suffix
+ if upper.endswith(doubled):
+ return upper[: -len(suffix)]
+ return ts_code
+
+
+def to_sina_symbol(value: str) -> str:
+ """Convert to Sina symbol format. IDEMPOTENT.
+
+ "000001.SZ" → "sz000001"
+ "000001" → "sz000001"
+ "sz000001" → "sz000001" (unchanged)
+ """
+ value = str(value).strip()
+ if _SINA_RE.match(value.lower()):
+ return value.lower()
+ ts = to_ts_code(value)
+ m = _TS_CODE_RE.match(ts)
+ if m:
+ code, exchange = m.groups()
+ return f"{exchange.lower()}{code}"
+ return value
+
+
+def bare_code(value: str) -> str:
+ """Extract the bare 6-digit code from any format.
+
+ "000001.SZ" → "000001"
+ "sz000001" → "000001"
+ """
+ ts = to_ts_code(value)
+ m = _TS_CODE_RE.match(ts)
+ if m:
+ return m.group(1)
+ return value
+
+
+def derive_market(ts_code: str) -> str:
+ """Derive market segment (板块) from ts_code.
+
+ 6xxxxx.SH (non-688) → 主板
+ 688xxx.SH → 科创板
+ 0xxxxx.SZ → 主板
+ 3xxxxx.SZ → 创业板
+ *.BJ → 北交所
+ """
+ ts = to_ts_code(ts_code).upper()
+ if ts.endswith(".SH"):
+ return "科创板" if ts.startswith("688") else "主板"
+ if ts.endswith(".SZ"):
+ return "创业板" if ts.startswith("3") else "主板"
+ if ts.endswith(".BJ"):
+ return "北交所"
+ return "其他"
diff --git a/src/ashare_dp/core/models.py b/src/ashare_dp/core/models.py
index 3e6b448..40efb0f 100644
--- a/src/ashare_dp/core/models.py
+++ b/src/ashare_dp/core/models.py
@@ -85,3 +85,30 @@ INTRADAY_FREQS: tuple[Freq, ...] = (Freq.m1, Freq.m5, Freq.m15, Freq.m30, Freq.h
# Frequencies that are derived at query time
DERIVED_FREQS: tuple[Freq, ...] = (Freq.h2,)
+
+# ── Index codes ──
+# ts_code format → Chinese name
+INDEX_CODES: dict[str, str] = {
+ "000001.SH": "上证指数",
+ "399001.SZ": "深证成指",
+ "399006.SZ": "创业板指",
+ "000688.SH": "科创50",
+ "000300.SH": "沪深300",
+ "000905.SH": "中证500",
+ "000852.SH": "中证1000",
+}
+
+# Sina API symbol format for index backfill
+INDEX_SINA_SYMBOLS: dict[str, str] = {
+ "sh000001": "000001.SH",
+ "sz399001": "399001.SZ",
+ "sz399006": "399006.SZ",
+ "sh000688": "000688.SH",
+ "sh000300": "000300.SH",
+ "sh000905": "000905.SH",
+ "sh000852": "000852.SH",
+}
+
+
+# derive_market moved to ashare_dp.core.codes
+from ashare_dp.core.codes import derive_market # noqa: F401, E402
diff --git a/src/ashare_dp/data/__init__.py b/src/ashare_dp/data/__init__.py
index e69de29..e08fd51 100644
--- a/src/ashare_dp/data/__init__.py
+++ b/src/ashare_dp/data/__init__.py
@@ -0,0 +1 @@
+"""DATA PLATFORM — data acquisition, ETL, and storage. Knows nothing about trading."""
diff --git a/src/ashare_dp/data/pipelines/__init__.py b/src/ashare_dp/data/pipelines/__init__.py
new file mode 100644
index 0000000..5176fbf
--- /dev/null
+++ b/src/ashare_dp/data/pipelines/__init__.py
@@ -0,0 +1 @@
+"""ETL pipelines: backfill, EOD, realtime."""
diff --git a/src/ashare_dp/data/backfill.py b/src/ashare_dp/data/pipelines/backfill.py
similarity index 95%
rename from src/ashare_dp/data/backfill.py
rename to src/ashare_dp/data/pipelines/backfill.py
index e130ab1..65ae5b4 100644
--- a/src/ashare_dp/data/backfill.py
+++ b/src/ashare_dp/data/pipelines/backfill.py
@@ -18,16 +18,17 @@ import pyarrow.parquet as pq
from loguru import logger
from ashare_dp.config import Settings
+from ashare_dp.core.codes import to_ts_code
from ashare_dp.core.exceptions import StorageError
from ashare_dp.core.models import (
BACKFILLABLE_FREQS,
INTRADAY_FREQS,
Freq,
)
-from ashare_dp.data.akshare_client import AKShareClient
-from ashare_dp.storage.database import get_db
-from ashare_dp.storage.repository import KLineRepository, _standardize_df
-from ashare_dp.storage.partitioning import partition_glob
+from ashare_dp.data.sources.akshare_client import AKShareClient
+from ashare_dp.data.store.database import get_db
+from ashare_dp.data.store.repository import KLineRepository, _standardize_df
+from ashare_dp.data.store.partitioning import partition_glob
settings = Settings()
@@ -78,16 +79,6 @@ LEGACY_MIN_COLUMN_MAPPING = {
REQUIRED_COLS = ["ts_code", "trade_time", "open", "high", "low", "close", "volume", "amount"]
-def _to_ts_code(symbol: str) -> str:
- """Convert 6-digit symbol to ts_code with exchange suffix."""
- code = str(symbol).zfill(6)
- if code.startswith(("4", "8")) or code.startswith("92"):
- return f"{code}.BJ"
- elif code.startswith("6") or code.startswith("9"):
- return f"{code}.SH"
- return f"{code}.SZ"
-
-
def _normalize_hist_df(df: pd.DataFrame, symbol: str, freq: Freq) -> pd.DataFrame:
"""Normalize daily K-line output to standard K-line schema."""
df = df.copy()
@@ -103,7 +94,7 @@ def _normalize_hist_df(df: pd.DataFrame, symbol: str, freq: Freq) -> pd.DataFram
break
if "ts_code" not in df.columns:
- df["ts_code"] = _to_ts_code(symbol)
+ df["ts_code"] = to_ts_code(symbol)
if "trade_time" in df.columns:
df["trade_time"] = pd.to_datetime(df["trade_time"])
@@ -135,7 +126,7 @@ def _normalize_min_df(df: pd.DataFrame, symbol: str) -> pd.DataFrame:
break
if "ts_code" not in df.columns:
- df["ts_code"] = _to_ts_code(symbol)
+ df["ts_code"] = to_ts_code(symbol)
if "trade_time" in df.columns:
df["trade_time"] = pd.to_datetime(df["trade_time"])
@@ -189,16 +180,6 @@ def _resample_daily_to_period(df: pd.DataFrame, freq: Freq) -> pd.DataFrame:
return result
-def _code_to_ts_code(code: str) -> str:
- """Convert bare 6-digit code to ts_code format: '000001' -> '000001.SZ'."""
- code = str(code).zfill(6)
- if code.startswith(("4", "8")) or code.startswith("92"):
- return f"{code}.BJ"
- elif code.startswith("6") or code.startswith("9"):
- return f"{code}.SH"
- return f"{code}.SZ"
-
-
WRITE_BATCH = 500 # Write to disk every N stocks to limit memory
@@ -217,7 +198,7 @@ class BackfillPipeline:
def init_db(self):
"""Initialize database schema (tables and views)."""
- from ashare_dp.storage.schema import DDL_STATEMENTS
+ from ashare_dp.data.store.schema import DDL_STATEMENTS
logger.info("Initializing database schema...")
with get_db(read_only=False) as db:
@@ -394,8 +375,8 @@ class BackfillPipeline:
chunk = pd.concat(batch_frames, ignore_index=True)
# Standardize: add freq, keep only standard columns
chunk["freq"] = Freq.d1.value
- # Convert bare codes (000001) to ts_code format (000001.SZ)
- chunk["ts_code"] = chunk["ts_code"].astype(str).apply(_code_to_ts_code)
+ # Normalize ts_code (idempotent — never double-suffixes)
+ chunk["ts_code"] = chunk["ts_code"].astype(str).apply(to_ts_code)
chunk = _standardize_df(chunk)
total_records += len(chunk)
table = pa.Table.from_pandas(chunk, preserve_index=False)
@@ -451,6 +432,7 @@ class BackfillPipeline:
logger.info(f"Merging {batch_idx} temp batches into final parquet...")
t0 = time.monotonic()
temp_glob = str(temp_dir / "batch_*.parquet")
+ import duckdb
copy_conn = duckdb.connect()
copy_conn.execute(f"""
COPY (
@@ -535,6 +517,7 @@ class BackfillPipeline:
logger.info(f"Deriving {freq.value} with single DuckDB scan...")
t0 = time.monotonic()
try:
+ import duckdb
derive_conn = duckdb.connect()
df = derive_conn.execute(sql).fetchdf()
derive_conn.close()
diff --git a/src/ashare_dp/data/eod.py b/src/ashare_dp/data/pipelines/eod.py
similarity index 96%
rename from src/ashare_dp/data/eod.py
rename to src/ashare_dp/data/pipelines/eod.py
index b837718..2b5253b 100644
--- a/src/ashare_dp/data/eod.py
+++ b/src/ashare_dp/data/pipelines/eod.py
@@ -14,9 +14,9 @@ from loguru import logger
from ashare_dp.core.calendar import BEIJING_TZ
from ashare_dp.core.models import BACKFILLABLE_FREQS, INTRADAY_FREQS, Freq
-from ashare_dp.data.akshare_client import AKShareClient
-from ashare_dp.data.backfill import _normalize_hist_df, _normalize_min_df, _resample_daily_to_period
-from ashare_dp.storage.repository import KLineRepository
+from ashare_dp.data.sources.akshare_client import AKShareClient
+from ashare_dp.data.pipelines.backfill import _normalize_hist_df, _normalize_min_df, _resample_daily_to_period
+from ashare_dp.data.store.repository import KLineRepository
class EODPipeline:
diff --git a/src/ashare_dp/data/realtime.py b/src/ashare_dp/data/pipelines/realtime.py
similarity index 64%
rename from src/ashare_dp/data/realtime.py
rename to src/ashare_dp/data/pipelines/realtime.py
index a542a72..9f8ce40 100644
--- a/src/ashare_dp/data/realtime.py
+++ b/src/ashare_dp/data/pipelines/realtime.py
@@ -11,32 +11,13 @@ from datetime import datetime
from loguru import logger
-from ashare_dp.api.websocket.manager import manager
+from ashare_dp.core.codes import to_ts_code
+
+from ashare_dp.apps.api.websocket.manager import manager
from ashare_dp.core.calendar import BEIJING_TZ, MarketState, determine_market_state
-from ashare_dp.data.akshare_client import AKShareClient
+from ashare_dp.data.sources.akshare_client import AKShareClient
-def _code_to_ts_code(code: str) -> str:
- """Convert a raw stock code to ts_code format.
-
- Handles both Sina format (with exchange prefix, e.g. 'sh600000')
- and numeric-only format (e.g. '000001').
- """
- code = str(code)
- # Sina format: 'sh600000', 'sz000001', 'bj920000'
- if code[:2].isalpha() and len(code) == 8:
- prefix = code[:2].lower()
- num = code[2:]
- exchange_map = {"sh": "SH", "sz": "SZ", "bj": "BJ"}
- return f"{num}.{exchange_map.get(prefix, prefix.upper())}"
- # Numeric format
- code = code.zfill(6)
- if code.startswith(("4", "8")) or code.startswith("92"):
- return f"{code}.BJ"
- elif code.startswith("6") or code.startswith("9"):
- return f"{code}.SH"
- return f"{code}.SZ"
-
class RealtimePoller:
"""Background task that polls market data and broadcasts to subscribers."""
@@ -50,6 +31,7 @@ class RealtimePoller:
self._poll_interval = poll_interval
self._running = False
self._task: asyncio.Task | None = None
+ self._last_snapshot: datetime | None = None
async def start(self):
"""Start the polling loop."""
@@ -105,7 +87,7 @@ class RealtimePoller:
if df is not None and not df.empty:
# Normalize and filter
if "代码" in df.columns:
- df["ts_code"] = df["代码"].apply(_code_to_ts_code)
+ df["ts_code"] = df["代码"].apply(to_ts_code)
spot_data = {}
for _, row in df.iterrows():
@@ -129,6 +111,17 @@ class RealtimePoller:
except Exception as e:
logger.error(f"Spot poll error: {e}")
+ # Intraday state snapshot every 30 minutes
+ if state == MarketState.TRADING and (
+ self._last_snapshot is None
+ or (now - self._last_snapshot).total_seconds() >= 1800
+ ):
+ try:
+ await asyncio.to_thread(_save_intraday_snapshot, now)
+ self._last_snapshot = now
+ except Exception as e:
+ logger.warning(f"Intraday snapshot failed: {e}")
+
# Heartbeat every 30 seconds
if (now - last_heartbeat).total_seconds() >= 30:
await manager.broadcast({
@@ -144,5 +137,73 @@ class RealtimePoller:
await asyncio.sleep(self._get_poll_interval(state))
+def _save_intraday_snapshot(now: datetime):
+ """Compute and save a lightweight intraday MarketState from spot data."""
+ import json
+ from ashare_dp.data.store.database import analytics_conn
+ from ashare_dp.data.sources.akshare_client import AKShareClient
+
+ client = AKShareClient()
+ df = client.get_spot()
+ if df is None or df.empty:
+ return
+
+ # Lightweight breadth from spot: advance/decline from pct_chg
+ pct_col = None
+ for c in ["涨跌幅", "pct_chg"]:
+ if c in df.columns:
+ pct_col = c
+ break
+ if pct_col is None:
+ return
+
+ pct_vals = df[pct_col].dropna().astype(float)
+ total = len(pct_vals)
+ if total == 0:
+ return
+ advances = int((pct_vals > 0).sum())
+ declines = int((pct_vals < 0).sum())
+ advance_ratio = advances / total
+
+ # Compute volume if available
+ total_amount = 0.0
+ amt_col = None
+ for c in ["成交额", "amount"]:
+ if c in df.columns:
+ amt_col = c
+ break
+ if amt_col:
+ total_amount = float(df[amt_col].dropna().sum())
+
+ # Lightweight state
+ state_dict = {
+ "version": "1.0",
+ "source": "intraday_spot",
+ "timestamp": now.isoformat(),
+ "trade_date": now.date().isoformat(),
+ "dimensions": {
+ "trend": round(advance_ratio, 4),
+ "fear": round(1.0 - advance_ratio, 4),
+ "liquidity": 0.5,
+ "rotation": 0.0,
+ "participation": round(advance_ratio, 4),
+ "volatility": 0.0,
+ "breadth": round(advance_ratio, 4),
+ },
+ "confidence": 0.5,
+ "quality": 0.5,
+ }
+
+ conn = analytics_conn(read_only=False)
+ try:
+ conn.execute(
+ "INSERT OR REPLACE INTO state_snapshot (entity, timeframe, timestamp, state) "
+ "VALUES (?, ?, ?, ?)",
+ ("market", "30min", now, json.dumps(state_dict, ensure_ascii=False)),
+ )
+ finally:
+ conn.close()
+
+
# Global poller instance
poller = RealtimePoller()
diff --git a/src/ashare_dp/data/sources/__init__.py b/src/ashare_dp/data/sources/__init__.py
new file mode 100644
index 0000000..722b9f9
--- /dev/null
+++ b/src/ashare_dp/data/sources/__init__.py
@@ -0,0 +1 @@
+"""External data sources: akshare, index, industry."""
diff --git a/src/ashare_dp/data/akshare_client.py b/src/ashare_dp/data/sources/akshare_client.py
similarity index 92%
rename from src/ashare_dp/data/akshare_client.py
rename to src/ashare_dp/data/sources/akshare_client.py
index 177a3f4..6cea2ef 100644
--- a/src/ashare_dp/data/akshare_client.py
+++ b/src/ashare_dp/data/sources/akshare_client.py
@@ -24,6 +24,7 @@ import urllib.request
from loguru import logger
from ashare_dp.config import Settings
+from ashare_dp.core.codes import to_sina_symbol, to_ts_code
from ashare_dp.core.exceptions import DataSourceError
settings = Settings()
@@ -66,33 +67,6 @@ def _to_akshare_date(d: date) -> str:
return d.strftime("%Y%m%d")
-def _code_to_sina_symbol(code: str) -> str:
- """Convert numeric stock code to Sina symbol format.
-
- '000001' -> 'sz000001', '600000' -> 'sh600000', '920000' -> 'bj920000'
- """
- code = str(code).zfill(6)
- if code.startswith(("4", "8")) or code.startswith("92"):
- return f"bj{code}"
- elif code.startswith("6") or code.startswith("9"):
- return f"sh{code}"
- else:
- return f"sz{code}"
-
-
-def _sina_symbol_to_ts_code(symbol: str) -> str:
- """Convert Sina symbol to ts_code format.
-
- 'sh600000' -> '600000.SH', 'sz000001' -> '000001.SZ', 'bj920000' -> '920000.BJ'
- """
- match = re.match(r'^([a-z]+)(\d{6})$', str(symbol))
- if match:
- prefix, code = match.groups()
- exchange = prefix.upper()
- return f"{code}.{exchange}"
- return str(symbol)
-
-
class AKShareClient:
"""Wrapper around akshare with retry, error handling, and dual backend.
@@ -228,7 +202,7 @@ class AKShareClient:
self, symbol: str, start_date: str, end_date: str, adjust: str
) -> pd.DataFrame:
import akshare as ak
- sina_symbol = _code_to_sina_symbol(symbol)
+ sina_symbol = to_sina_symbol(symbol)
return self._retry(lambda: ak.stock_zh_a_daily(
symbol=sina_symbol,
start_date=start_date,
@@ -286,7 +260,7 @@ class AKShareClient:
start_date: str | None, end_date: str | None,
) -> pd.DataFrame:
import akshare as ak
- sina_symbol = _code_to_sina_symbol(symbol)
+ sina_symbol = to_sina_symbol(symbol)
df = self._retry(lambda: ak.stock_zh_a_minute(
symbol=sina_symbol, period=period,
))
diff --git a/src/ashare_dp/data/sources/index.py b/src/ashare_dp/data/sources/index.py
new file mode 100644
index 0000000..634625f
--- /dev/null
+++ b/src/ashare_dp/data/sources/index.py
@@ -0,0 +1,117 @@
+"""Index daily K-line backfill via Sina API.
+
+Fetches 7 major A-share indices and stores them in the same
+Hive-partitioned Parquet format as stock K-lines.
+
+Uses akshare.stock_zh_index_daily() which works via Sina
+(verified accessible outside China).
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+import pandas as pd
+from loguru import logger
+
+from ashare_dp.core.models import INDEX_SINA_SYMBOLS, Freq
+from ashare_dp.data.store.repository import KLineRepository
+
+
+def backfill_indices(
+ repo: KLineRepository | None = None,
+ start_date: str = "19900101",
+ end_date: str | None = None,
+) -> dict[str, int]:
+ """Fetch and store index daily K-line history.
+
+ Args:
+ repo: KLineRepository instance. Created if None.
+ start_date: Start date in YYYYMMDD format.
+ end_date: End date in YYYYMMDD format (default: today).
+
+ Returns:
+ Dict mapping ts_code → number of bars stored.
+ """
+ if repo is None:
+ repo = KLineRepository()
+
+ if end_date is None:
+ end_date = date.today().strftime("%Y%m%d")
+
+ import akshare as ak
+
+ results: dict[str, int] = {}
+
+ for sina_sym, ts_code in INDEX_SINA_SYMBOLS.items():
+ try:
+ logger.info(f"Fetching index {sina_sym} ({ts_code})...")
+ df = ak.stock_zh_index_daily(symbol=sina_sym)
+ if df is None or df.empty:
+ logger.warning(f"No data for {sina_sym}")
+ results[ts_code] = 0
+ continue
+
+ # Normalize columns to match K-line standard schema
+ df = _normalize_index_df(df, ts_code)
+
+ # Filter by date range
+ df["trade_date"] = pd.to_datetime(df["trade_date"])
+ df = df[(df["trade_date"] >= start_date) & (df["trade_date"] <= end_date)]
+
+ if df.empty:
+ logger.warning(f"No data in range for {sina_sym}")
+ results[ts_code] = 0
+ continue
+
+ # Store
+ repo.write_klines(df, Freq.d1)
+ results[ts_code] = len(df)
+ logger.info(f" {sina_sym}: stored {len(df)} bars, "
+ f"{df['trade_date'].min().date()} ~ {df['trade_date'].max().date()}")
+
+ except Exception as e:
+ logger.error(f"Failed to backfill index {sina_sym}: {e}")
+ results[ts_code] = 0
+
+ total = sum(results.values())
+ logger.info(f"Index backfill complete: {total} total bars across {len(results)} indices")
+ return results
+
+
+def _normalize_index_df(df: pd.DataFrame, ts_code: str) -> pd.DataFrame:
+ """Normalize index DataFrame to match stock K-line standard schema.
+
+ Sina index columns: date, open, high, low, close, volume
+ Missing: amount (not available for indices from Sina)
+ """
+ df = df.copy()
+
+ # Standardize column names
+ col_map = {
+ "date": "trade_time",
+ "open": "open",
+ "high": "high",
+ "low": "low",
+ "close": "close",
+ "volume": "volume",
+ }
+ df = df.rename(columns=col_map)
+
+ # Add required columns
+ df["ts_code"] = ts_code
+ df["trade_date"] = pd.to_datetime(df["trade_time"])
+ df["freq"] = Freq.d1.value
+
+ # Index data from Sina doesn't have amount
+ if "amount" not in df.columns:
+ df["amount"] = 0.0
+
+ # Ensure volume is int
+ df["volume"] = df["volume"].fillna(0).astype("int64")
+
+ # Select and order standard columns
+ std_cols = ["ts_code", "trade_time", "trade_date", "open", "high", "low", "close", "volume", "amount", "freq"]
+ df = df[[c for c in std_cols if c in df.columns]]
+
+ return df
diff --git a/src/ashare_dp/data/sources/industry.py b/src/ashare_dp/data/sources/industry.py
new file mode 100644
index 0000000..3e36072
--- /dev/null
+++ b/src/ashare_dp/data/sources/industry.py
@@ -0,0 +1,165 @@
+"""Industry classification data fetch and storage.
+
+Fetches industry classifications from akshare and stores in DuckDB.
+Used by rotation features and dashboard sector analysis.
+"""
+
+from __future__ import annotations
+
+import pandas as pd
+from loguru import logger
+
+from ashare_dp.core.codes import to_ts_code
+from ashare_dp.data.store.database import get_db
+
+
+def fetch_industry_mapping() -> pd.DataFrame:
+ """Fetch stock-to-industry mapping from akshare.
+
+ Tries East Money first (richer data), falls back to Sina.
+ Returns DataFrame with columns: ts_code, industry_name
+ """
+ logger.info("Fetching industry classifications...")
+
+ try:
+ import akshare as ak
+ # Try East Money industry constituent data
+ df = ak.stock_board_industry_cons_em()
+ if df is not None and not df.empty:
+ logger.info(f"East Money industry data: {len(df)} rows")
+ # Expected columns vary by akshare version
+ # Common: '代码', '名称', '所属行业' or similar
+ cols = df.columns.tolist()
+ logger.debug(f"Industry columns: {cols}")
+
+ # Normalize to ts_code + industry_name
+ code_col = None
+ name_col = None
+
+ for c in cols:
+ if "代码" in c or c.lower() in ("code", "symbol"):
+ code_col = c
+ if "行业" in c or c.lower() in ("industry", "board"):
+ name_col = c
+
+ if code_col and name_col:
+ result = pd.DataFrame({
+ "code": df[code_col].astype(str).str.zfill(6),
+ "industry_name": df[name_col].astype(str),
+ })
+ # Convert code to ts_code format
+ result["ts_code"] = result["code"].apply(to_ts_code)
+ result = result[["ts_code", "industry_name"]].drop_duplicates(subset=["ts_code"])
+ logger.info(f"Normalized {len(result)} stock-industry mappings")
+ return result
+ except Exception as e:
+ logger.warning(f"East Money industry fetch failed: {e}")
+
+ # Fallback: try Sina
+ try:
+ import akshare as ak
+ df = ak.stock_info_a_code_name()
+ if df is not None and not df.empty:
+ # Sina stock_info doesn't have industry, but we can try other endpoints
+ logger.info("Sina fallback: no industry data available from basic stock info")
+ except Exception as e2:
+ logger.warning(f"Sina fallback also failed: {e2}")
+
+ # Last resort: derive a simple industry grouping from stock names
+ logger.warning("No industry API available. Using minimal fallback.")
+ return _minimal_fallback()
+
+
+def _minimal_fallback() -> pd.DataFrame:
+ """Generate minimal industry mapping from existing stock_info.
+
+ Phase 1: if no API works, use market + simple heuristics.
+ The rotation module will fall back to market-based segments.
+ """
+ with get_db(read_only=True) as db:
+ result = db.query("SELECT ts_code, name, market FROM stock_info")
+ rows = []
+ for ts_code, name, market in result:
+ industry = _guess_industry_from_name(name, ts_code)
+ rows.append({"ts_code": ts_code, "industry_name": industry})
+
+ df = pd.DataFrame(rows)
+ logger.info(f"Generated {len(df)} fallback industry mappings")
+ return df
+
+
+def _guess_industry_from_name(name: str, ts_code: str) -> str:
+ """Guess industry from stock name using keyword matching."""
+ name = str(name)
+ keywords = [
+ ("银行", "银行"),
+ ("证券", "证券"),
+ ("保险", "保险"),
+ ("房地产", "房地产"),
+ ("医药", "医药"),
+ ("汽车", "汽车"),
+ ("钢铁", "钢铁"),
+ ("煤炭", "煤炭"),
+ ("电力", "电力"),
+ ("石油", "石油"),
+ ("化工", "化工"),
+ ("有色", "有色"),
+ ("食品", "食品饮料"),
+ ("饮料", "食品饮料"),
+ ("白酒", "白酒"),
+ ("家电", "家电"),
+ ("半导体", "半导体"),
+ ("芯片", "半导体"),
+ ("通信", "通信"),
+ ("软件", "软件"),
+ ("计算机", "计算机"),
+ ("传媒", "传媒"),
+ ("军工", "军工"),
+ ("航空", "军工"),
+ ("机器人", "机器人"),
+ ("光伏", "光伏"),
+ ("锂电", "新能源"),
+ ("电池", "新能源"),
+ ("风电", "新能源"),
+ ("储能", "新能源"),
+ ("新能源", "新能源"),
+ ("AI", "AI"),
+ ("算力", "AI"),
+ ("光模块", "AI"),
+ ("PCB", "PCB"),
+ ("船舶", "船舶"),
+ ("港口", "交通运输"),
+ ("铁路", "交通运输"),
+ ("公路", "交通运输"),
+ ("航空运输", "交通运输"),
+ ("建筑", "建筑"),
+ ("建材", "建材"),
+ ("环保", "环保"),
+ ]
+ for keyword, industry in keywords:
+ if keyword in name:
+ return industry
+ return "其他"
+
+
+
+
+def store_industry_mapping(df: pd.DataFrame) -> int:
+ """Store industry mappings in DuckDB stock_industry table.
+
+ Returns number of rows stored.
+ """
+ with get_db(read_only=False) as db:
+ try:
+ for _, row in df.iterrows():
+ db.execute(
+ "INSERT OR REPLACE INTO stock_industry (ts_code, industry_name) VALUES (?, ?)",
+ (row["ts_code"], row["industry_name"]),
+ )
+ count_result = db.query("SELECT COUNT(*) FROM stock_industry")
+ count = count_result[0][0] if count_result else 0
+ logger.info(f"Stored {count} industry mappings in stock_industry")
+ return int(count)
+ except Exception as e:
+ logger.error(f"Failed to store industry data: {e}")
+ return 0
diff --git a/src/ashare_dp/data/store/__init__.py b/src/ashare_dp/data/store/__init__.py
new file mode 100644
index 0000000..67eef4f
--- /dev/null
+++ b/src/ashare_dp/data/store/__init__.py
@@ -0,0 +1,6 @@
+"""Storage layer: DuckDB + Parquet."""
+from ashare_dp.data.store.database import get_db, Database, analytics_conn
+from ashare_dp.data.store.repository import KLineRepository
+from ashare_dp.data.store.partitioning import partition_glob, partition_path
+
+__all__ = ["get_db", "Database", "analytics_conn", "KLineRepository", "partition_glob", "partition_path"]
diff --git a/src/ashare_dp/storage/database.py b/src/ashare_dp/data/store/database.py
similarity index 73%
rename from src/ashare_dp/storage/database.py
rename to src/ashare_dp/data/store/database.py
index 8392478..afce22a 100644
--- a/src/ashare_dp/storage/database.py
+++ b/src/ashare_dp/data/store/database.py
@@ -99,3 +99,31 @@ def get_db(read_only: bool = True) -> Iterator[Database]:
yield db
finally:
db.close()
+
+
+def analytics_conn(read_only: bool = True) -> "duckdb.DuckDBPyConnection":
+ """Raw DuckDB connection for analytics queries (features, engines).
+
+ The single sanctioned way to open a DuckDB connection outside the
+ Database wrapper. Uses configured duckdb_path — never hardcode paths.
+ Caller must close() the connection.
+ """
+ return duckdb.connect(settings.duckdb_path, read_only=read_only)
+
+
+def kline_glob(freq=None) -> str:
+ """Parquet glob for K-line data at the given frequency (default: daily).
+
+ Convenience re-export so analytics modules don't hardcode paths.
+ """
+ from ashare_dp.core.models import Freq
+ from ashare_dp.data.store.partitioning import partition_glob
+ return partition_glob(freq or Freq.d1)
+
+
+def read_parquet_sql(glob_var: str) -> str:
+ """Standard read_parquet SQL snippet with hive partitioning.
+
+ Single place for the 28 duplicate 'read_parquet({glob}, hive_partitioning=true, union_by_name=true)' patterns.
+ """
+ return f"read_parquet('{glob_var}', hive_partitioning=true, union_by_name=true)"
diff --git a/src/ashare_dp/storage/partitioning.py b/src/ashare_dp/data/store/partitioning.py
similarity index 100%
rename from src/ashare_dp/storage/partitioning.py
rename to src/ashare_dp/data/store/partitioning.py
diff --git a/src/ashare_dp/storage/repository.py b/src/ashare_dp/data/store/repository.py
similarity index 98%
rename from src/ashare_dp/storage/repository.py
rename to src/ashare_dp/data/store/repository.py
index 86e9ace..3381dec 100644
--- a/src/ashare_dp/storage/repository.py
+++ b/src/ashare_dp/data/store/repository.py
@@ -15,8 +15,8 @@ from loguru import logger
from ashare_dp.config import Settings
from ashare_dp.core.exceptions import QueryError, StorageError
from ashare_dp.core.models import DERIVED_FREQS, Freq
-from ashare_dp.storage.database import get_db
-from ashare_dp.storage.partitioning import (
+from ashare_dp.data.store.database import get_db
+from ashare_dp.data.store.partitioning import (
ensure_partition_dir,
partition_glob,
)
diff --git a/src/ashare_dp/data/store/schema.py b/src/ashare_dp/data/store/schema.py
new file mode 100644
index 0000000..0ce2678
--- /dev/null
+++ b/src/ashare_dp/data/store/schema.py
@@ -0,0 +1,138 @@
+"""Database schema: DDL statements for DuckDB tables and views."""
+
+from __future__ import annotations
+
+# DDL for persistent tables
+DDL_STATEMENTS = [
+ """
+ CREATE TABLE IF NOT EXISTS stock_info (
+ ts_code VARCHAR(9) PRIMARY KEY,
+ symbol VARCHAR(6) NOT NULL,
+ name VARCHAR(40) NOT NULL,
+ exchange VARCHAR(2) NOT NULL,
+ area VARCHAR(20),
+ industry VARCHAR(40),
+ list_date DATE,
+ delist_date DATE,
+ market VARCHAR(10),
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_stock_symbol ON stock_info(symbol)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_stock_exchange ON stock_info(exchange)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS trading_calendar (
+ trade_date DATE PRIMARY KEY,
+ is_trading_day BOOLEAN NOT NULL DEFAULT TRUE,
+ week_day TINYINT NOT NULL,
+ year SMALLINT NOT NULL,
+ month TINYINT NOT NULL
+ )
+ """,
+ """
+ CREATE SEQUENCE IF NOT EXISTS seq_ema52_id
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS ema52_screening (
+ id BIGINT PRIMARY KEY DEFAULT nextval('seq_ema52_id'),
+ trade_date DATE NOT NULL,
+ ts_code VARCHAR(9) NOT NULL,
+ name VARCHAR(40),
+ freq VARCHAR(3) NOT NULL,
+ close_price DOUBLE NOT NULL,
+ ema52 DOUBLE NOT NULL,
+ distance_pct DOUBLE NOT NULL,
+ amount DOUBLE,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(trade_date, ts_code, freq)
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_ema52_date ON ema52_screening(trade_date)
+ """,
+ # Migration: add amount column if upgrading from older schema
+ """
+ ALTER TABLE ema52_screening ADD COLUMN IF NOT EXISTS amount DOUBLE
+ """,
+ # ── Dashboard / Trading OS tables ──
+ """
+ CREATE TABLE IF NOT EXISTS stock_industry (
+ ts_code VARCHAR(9) NOT NULL,
+ industry_name VARCHAR(40) NOT NULL,
+ industry_code VARCHAR(20),
+ PRIMARY KEY (ts_code)
+ )
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_stock_industry_name ON stock_industry(industry_name)
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS state_snapshot (
+ entity VARCHAR(20) NOT NULL,
+ timeframe VARCHAR(10) NOT NULL,
+ timestamp TIMESTAMP NOT NULL,
+ state JSON NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (entity, timeframe, timestamp)
+ )
+ """,
+ # ── Signal Intelligence ──
+ """
+ CREATE TABLE IF NOT EXISTS signal_instance (
+ id BIGINT PRIMARY KEY DEFAULT nextval('seq_signal_id'),
+ signal_type VARCHAR(30) NOT NULL,
+ ts_code VARCHAR(15) NOT NULL,
+ trade_date DATE NOT NULL,
+ signal_price DOUBLE NOT NULL,
+ -- Market state at signal time
+ state_trend DOUBLE,
+ state_fear DOUBLE,
+ state_liquidity DOUBLE,
+ state_rotation DOUBLE,
+ state_participation DOUBLE,
+ state_volatility DOUBLE,
+ state_breadth DOUBLE,
+ -- Forward outcomes
+ return_5d DOUBLE,
+ return_10d DOUBLE,
+ return_20d DOUBLE,
+ max_return_5d DOUBLE,
+ max_drawdown_5d DOUBLE,
+ holding_days INTEGER,
+ outcome_known BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(signal_type, ts_code, trade_date)
+ )
+ """,
+ """
+ CREATE SEQUENCE IF NOT EXISTS seq_signal_id
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_signal_type_date ON signal_instance(signal_type, trade_date)
+ """,
+ """
+ CREATE INDEX IF NOT EXISTS idx_signal_ts_code ON signal_instance(ts_code, trade_date)
+ """,
+ # ── Trading Memory ──
+ """
+ CREATE TABLE IF NOT EXISTS trade_log (
+ id BIGINT PRIMARY KEY DEFAULT nextval('seq_trade_id'),
+ ts_code VARCHAR(15) NOT NULL, trade_date DATE NOT NULL,
+ entry_price DOUBLE NOT NULL, entry_time TIMESTAMP,
+ signal_type VARCHAR(30), position_pct DOUBLE DEFAULT 0,
+ exit_price DOUBLE, exit_time TIMESTAMP, exit_reason VARCHAR(20),
+ return_pct DOUBLE, holding_days INTEGER,
+ max_favorable DOUBLE, max_adverse DOUBLE,
+ state_trend DOUBLE, state_fear DOUBLE, state_liquidity DOUBLE, state_breadth DOUBLE,
+ tags VARCHAR(200), notes VARCHAR(500),
+ closed BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """,
+ """CREATE SEQUENCE IF NOT EXISTS seq_trade_id""",
+ """CREATE INDEX IF NOT EXISTS idx_trade_date ON trade_log(trade_date)""",
+ """CREATE INDEX IF NOT EXISTS idx_trade_signal ON trade_log(signal_type)""",
+]
diff --git a/src/ashare_dp/domain/__init__.py b/src/ashare_dp/domain/__init__.py
new file mode 100644
index 0000000..dee1be1
--- /dev/null
+++ b/src/ashare_dp/domain/__init__.py
@@ -0,0 +1,35 @@
+"""Domain objects for the A-Share Data Platform.
+
+These are the foundational types shared by all modules:
+Feature Store, Market State Engine, Decision Engine, Dashboard, and future
+Signal Expectancy Engine / AI Agent / Backtester.
+"""
+
+from ashare_dp.domain.state import MarketState
+from ashare_dp.domain.events import RiskEvent, RotationEdge, RotationGraph
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.domain.context import (
+ TradingContext, Playbook, Expectancy, Opportunity,
+ Allocation, FlowEdge, FlowGraph,
+)
+from ashare_dp.domain.leadership import LeaderState
+
+from ashare_dp.domain.signal import SignalType, SignalInstance
+
+__all__ = [
+ "MarketState",
+ "RiskEvent",
+ "RotationEdge",
+ "RotationGraph",
+ "FeatureDefinition",
+ "TradingContext",
+ "Playbook",
+ "Expectancy",
+ "Opportunity",
+ "Allocation",
+ "FlowEdge",
+ "FlowGraph",
+ "LeaderState",
+ "SignalType",
+ "SignalInstance",
+]
diff --git a/src/ashare_dp/domain/context.py b/src/ashare_dp/domain/context.py
new file mode 100644
index 0000000..fe738db
--- /dev/null
+++ b/src/ashare_dp/domain/context.py
@@ -0,0 +1,120 @@
+"""TradingContext and related domain objects — the true center of the Trading OS.
+
+MarketState describes the market. TradingContext describes a trading decision.
+Signal Expectancy Engine queries history against this.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, date
+from typing import Optional
+
+from ashare_dp.domain.state import MarketState
+
+
+@dataclass
+class Allocation:
+ """Suggested capital allocation based on market state."""
+ cash_pct: float = 0.4
+ trend_pct: float = 0.4
+ trial_pct: float = 0.2
+ reasoning: str = ""
+
+
+@dataclass
+class Playbook:
+ """Complete trading playbook — not just a recommendation list.
+
+ Tells the trader: what strategies work, what to avoid,
+ how long to hold, how much to risk.
+ """
+ suitable_strategies: list[str] = field(default_factory=list)
+ unsuitable_strategies: list[str] = field(default_factory=list)
+ holding_time: str = "2~4 Days"
+ risk_budget: str = "Medium"
+ position_sizing: str = "Standard"
+ focus_sectors: list[str] = field(default_factory=list)
+ avoid_sectors: list[str] = field(default_factory=list)
+ bias: str = "NEUTRAL"
+ confidence: float = 0.5
+
+
+@dataclass
+class Expectancy:
+ """Historical expectancy for a signal in the current market context.
+
+ This is the moat. Query: Signal × MarketState → historical outcomes.
+ Phase 1: rule-based heuristic. Phase 2: real historical stats.
+ """
+ signal_type: str = ""
+ win_rate: float = 0.0
+ avg_return: float = 0.0
+ max_drawdown: float = 0.0
+ avg_holding_days: float = 0.0
+ sample_count: int = 0
+ confidence: float = 0.0
+ similar_states: int = 0
+
+
+@dataclass
+class Opportunity:
+ """A trading opportunity — not an industry score, a tradable object.
+
+ AI Agent can consume this directly without re-reasoning.
+ """
+ name: str = ""
+ score: float = 0.0
+ lifecycle: str = "" # "Early" | "Accelerating" | "Peak" | "Declining"
+ persistence_days: int = 0
+ catalyst: str = "" # "资金轮动" | "政策催化" | "业绩驱动"
+ leader_stock: str = ""
+ invalidation: str = "" # what would invalidate this opportunity
+ risk: str = ""
+ top_stocks: list[str] = field(default_factory=list)
+
+
+@dataclass
+class FlowEdge:
+ """Money flow edge in the flow graph."""
+ source: str = ""
+ target: str = ""
+ magnitude: str = "" # "↓↓→↑↑"
+ strength: float = 0.0
+
+
+@dataclass
+class FlowGraph:
+ """Money flow as a directed graph."""
+ flows: list[FlowEdge] = field(default_factory=list)
+ net_inflow: dict[str, float] = field(default_factory=dict)
+ net_outflow: dict[str, float] = field(default_factory=dict)
+
+
+@dataclass
+class TradingContext:
+ """The universal context for any trading decision.
+
+ Signal Expectancy Engine queries history against this object.
+ Every downstream consumer (Dashboard, AI, Backtest) reads this,
+ not raw features or individual engine outputs.
+ """
+ timestamp: datetime = field(default_factory=datetime.now)
+ trade_date: date = field(default_factory=date.today)
+
+ # L1: Market State
+ state: Optional[MarketState] = None
+
+ # L2: Interpretation
+ leaders: dict = field(default_factory=dict) # industry → LeaderState
+ opportunities: list[Opportunity] = field(default_factory=list)
+ money_flow: Optional[FlowGraph] = None
+
+ # L3: Intelligence
+ playbook: Optional[Playbook] = None
+ expectancy: Optional[Expectancy] = None
+ allocation: Optional[Allocation] = None
+ risk_budget: str = "Medium"
+
+ # Narrative
+ narrative: str = ""
diff --git a/src/ashare_dp/domain/events.py b/src/ashare_dp/domain/events.py
new file mode 100644
index 0000000..370024f
--- /dev/null
+++ b/src/ashare_dp/domain/events.py
@@ -0,0 +1,73 @@
+"""RiskEvent, RotationEdge, RotationGraph — structured domain events.
+
+Dashboard renders them, AI explains them, Backtest counts them.
+NOT raw dicts or strings.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any
+
+
+@dataclass
+class RiskEvent:
+ """Structured risk warning emitted by the Risk Rule Engine.
+
+ Dashboard renders the message with color-coded severity.
+ AI Agent can explain the event context.
+ Backtester can count event frequency by type.
+ """
+
+ type: str # "BreadthCollapse", "VolumeDrying", "LeaderFailure", etc.
+ severity: float # 0.0–1.0, how severe
+ level: str # "warning" | "danger"
+ affected: str # "All" | "主板" | "AI" | specific entity
+ confidence: float # 0.0–1.0, how confident the rule is in this detection
+ message: str # human-readable description
+ payload: dict = field(default_factory=dict) # type-specific data
+ timestamp: datetime = field(default_factory=datetime.now)
+
+ def to_dict(self) -> dict:
+ return {
+ "type": self.type,
+ "severity": round(self.severity, 4),
+ "level": self.level,
+ "affected": self.affected,
+ "confidence": round(self.confidence, 4),
+ "message": self.message,
+ "timestamp": self.timestamp.isoformat(),
+ }
+
+
+@dataclass
+class RotationEdge:
+ """Directed edge in the rotation graph.
+
+ Represents capital flowing FROM one sector TO another,
+ inferred from turnover changes.
+ """
+
+ source: str # sector name, e.g. "AI"
+ target: str # sector name, e.g. "机器人"
+ strength: float # 0.0–1.0, how strong the flow
+ confidence: float # 0.0–1.0
+ source_change: float # turnover change % for source (negative = outflow)
+ target_change: float # turnover change % for target (positive = inflow)
+ persistence_days: int # how many consecutive days this flow persisted
+
+
+@dataclass
+class RotationGraph:
+ """Sector rotation as a directed graph.
+
+ Nodes = industries/concepts.
+ Edges = capital flow direction + strength.
+
+ Future: AI can do graph search over this structure.
+ """
+
+ nodes: list[str] # all industry/concept names
+ edges: list[RotationEdge] # directed flows, sorted by strength desc
+ timestamp: datetime = field(default_factory=datetime.now)
diff --git a/src/ashare_dp/domain/features.py b/src/ashare_dp/domain/features.py
new file mode 100644
index 0000000..4447112
--- /dev/null
+++ b/src/ashare_dp/domain/features.py
@@ -0,0 +1,27 @@
+"""FeatureDefinition — metadata for a registered feature.
+
+Each feature in the Feature Registry is described by one of these.
+The Engine code only knows about FeatureDefinitions, never imports
+individual feature functions directly.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Callable
+
+
+@dataclass
+class FeatureDefinition:
+ """Metadata for a registered market feature.
+
+ Adding a new feature means registering a FeatureDefinition.
+ No engine code changes needed.
+ """
+
+ name: str # unique identifier, e.g. "breadth_vector"
+ category: str # "breadth", "volume", "trend", "fear", "rotation", "participation"
+ description: str # human-readable explanation
+ dependencies: list[str] = field(default_factory=list) # feature names this depends on
+ compute: Callable | None = None # the actual computation function (set after registration)
+ version: str = "1.0"
diff --git a/src/ashare_dp/domain/knowledge.py b/src/ashare_dp/domain/knowledge.py
new file mode 100644
index 0000000..890ec43
--- /dev/null
+++ b/src/ashare_dp/domain/knowledge.py
@@ -0,0 +1,95 @@
+"""Knowledge Graph domain model — Theme → Concept → Industry → Leader.
+
+The ontology that connects abstract trading themes (e.g. "AI", "新能源")
+to concrete industries and stocks. Used by narrative generation, rotation
+detection, and future AI agent reasoning.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass
+class Concept:
+ """A mid-level concept within a theme — e.g. "算力" within "AI"."""
+ name: str # "算力"
+ industries: list[str] = field(default_factory=list) # industry names from stock_industry
+ keywords: list[str] = field(default_factory=list) # keywords to match stock names
+ leaders: list[str] = field(default_factory=list) # representative stocks
+
+
+@dataclass
+class Theme:
+ """A high-level trading theme — e.g. "AI", "新能源", "机器人"."""
+ name: str
+ concepts: list[Concept] = field(default_factory=list)
+ relevance_score: float = 0.0 # 0-100, how active this theme is today
+ momentum: str = "" # "↑↑" | "↑" | "→" | "↓" | "↓↓"
+
+
+@dataclass
+class ThemeEdge:
+ """Directed edge: money flowing from one theme to another."""
+ source: str
+ target: str
+ strength: float = 0.0
+
+
+@dataclass
+class ThemeGraph:
+ """The complete theme landscape for today."""
+ themes: list[Theme] = field(default_factory=list)
+ edges: list[ThemeEdge] = field(default_factory=list)
+
+
+# ═══════════════════════════════════════════════
+# Curated A-Share Theme Knowledge Base
+# ═══════════════════════════════════════════════
+
+THEME_KNOWLEDGE: list[Theme] = [
+ Theme(name="AI", concepts=[
+ Concept(name="算力", industries=[], keywords=["算力", "服务器", "数据中心", "GPU", "CP", "曙光", "浪潮", "紫光", "中科曙光", "工业富联"], leaders=["工业富联", "中科曙光"]),
+ Concept(name="PCB", industries=[], keywords=["PCB", "电路板", "印制板", "沪电", "深南电路", "鹏鼎", "景旺", "胜宏"], leaders=["沪电股份", "深南电路"]),
+ Concept(name="光模块", industries=[], keywords=["光模块", "光通信", "中际", "新易盛", "天孚", "旭创", "剑桥", "光迅"], leaders=["中际旭创", "新易盛"]),
+ Concept(name="AI应用", industries=[], keywords=["AI应用", "大模型", "智能", "软件", "科大讯飞", "商汤", "云从", "格灵"], leaders=["科大讯飞"]),
+ Concept(name="半导体", industries=[], keywords=["半导体", "芯片", "晶圆", "封装", "设备", "韦尔", "兆易", "北方华创", "中微", "长电"], leaders=["北方华创"]),
+ ]),
+ Theme(name="机器人", concepts=[
+ Concept(name="减速器", industries=[], keywords=["减速器", "谐波", "绿的", "双环传动", "中大力德"], leaders=["绿的谐波"]),
+ Concept(name="伺服电机", industries=[], keywords=["伺服", "电机", "汇川", "禾川", "埃斯顿", "雷赛"], leaders=["汇川技术", "埃斯顿"]),
+ Concept(name="传感器", industries=[], keywords=["传感器", "视觉", "力觉", "奥普特", "天准"], leaders=["奥普特"]),
+ Concept(name="本体集成", industries=[], keywords=["机器人", "新松", "拓斯达", "埃夫特", "博实"], leaders=["新松机器人"]),
+ ]),
+ Theme(name="新能源", concepts=[
+ Concept(name="光伏", industries=[], keywords=["光伏", "隆基", "通威", "晶澳", "天合", "晶科", "阳光电源", "锦浪"], leaders=["隆基绿能", "阳光电源"]),
+ Concept(name="锂电", industries=[], keywords=["锂电", "宁德", "比亚迪", "亿纬", "国轩", "赣锋", "天齐", "华友", "中伟"], leaders=["宁德时代"]),
+ Concept(name="风电", industries=[], keywords=["风电", "金风", "明阳", "运达", "东方电缆", "大金重工"], leaders=["金风科技"]),
+ Concept(name="储能", industries=[], keywords=["储能", "派能", "科士达", "盛弘", "固德威", "德业"], leaders=["派能科技"]),
+ ]),
+ Theme(name="消费", concepts=[
+ Concept(name="白酒", industries=[], keywords=["白酒", "茅台", "五粮液", "泸州老窖", "汾酒", "洋河", "古井"], leaders=["贵州茅台"]),
+ Concept(name="家电", industries=[], keywords=["家电", "美的", "格力", "海尔", "海信", "老板"], leaders=["美的集团"]),
+ Concept(name="食品饮料", industries=[], keywords=["食品", "饮料", "伊利", "海天", "双汇", "桃李", "安井"], leaders=["伊利股份"]),
+ ]),
+ Theme(name="证券金融", concepts=[
+ Concept(name="证券", industries=[], keywords=["证券", "中信", "华泰", "国泰", "海通", "东方财富"], leaders=["东方财富"]),
+ Concept(name="保险", industries=[], keywords=["保险", "平安", "太保", "人寿", "新华"], leaders=["中国平安"]),
+ Concept(name="银行", industries=[], keywords=["银行", "招商", "兴业", "平安银行", "宁波银行"], leaders=["招商银行"]),
+ ]),
+ Theme(name="军工", concepts=[
+ Concept(name="航空", industries=[], keywords=["航空", "发动机", "飞机", "沈飞", "西飞", "航发", "中航"], leaders=["中航沈飞"]),
+ Concept(name="船舶", industries=[], keywords=["船舶", "中国船舶", "中船", "重工"], leaders=["中国船舶"]),
+ Concept(name="导弹武器", industries=[], keywords=["导弹", "兵器", "光电", "航天"], leaders=["航天电器"]),
+ ]),
+ Theme(name="医药", concepts=[
+ Concept(name="创新药", industries=[], keywords=["创新药", "恒瑞", "百济", "信达", "君实", "荣昌"], leaders=["恒瑞医药"]),
+ Concept(name="CXO", industries=[], keywords=["CXO", "药明", "康龙", "泰格", "凯莱英"], leaders=["药明康德"]),
+ Concept(name="医疗器械", industries=[], keywords=["医疗", "器械", "迈瑞", "联影", "鱼跃", "微创"], leaders=["迈瑞医疗"]),
+ ]),
+ Theme(name="汽车", concepts=[
+ Concept(name="整车", industries=[], keywords=["汽车", "整车", "比亚迪", "长城", "长安", "吉利", "上汽"], leaders=["比亚迪"]),
+ Concept(name="零部件", industries=[], keywords=["零部件", "配件", "华域", "均胜", "拓普", "三花"], leaders=["拓普集团"]),
+ Concept(name="智能驾驶", industries=[], keywords=["智能驾驶", "自动驾驶", "ADAS", "德赛西威", "中科创达", "经纬恒润"], leaders=["德赛西威"]),
+ ]),
+]
diff --git a/src/ashare_dp/domain/leadership.py b/src/ashare_dp/domain/leadership.py
new file mode 100644
index 0000000..fbee362
--- /dev/null
+++ b/src/ashare_dp/domain/leadership.py
@@ -0,0 +1,21 @@
+"""LeaderLifecycle — full lifecycle model for sector leaders.
+
+Not just Alive/Dead. Traders track the entire lifecycle:
+Birth → Growing → Leading → Exhausted → Breaking → Dead → Recovering
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+
+
+class LeaderState(str, Enum):
+ """Full leader lifecycle — not binary Alive/Dead."""
+ BIRTH = "birth" # first breakout from base
+ GROWING = "growing" # accelerating, above MAs, volume expanding
+ LEADING = "leading" # at peak, driving sector, strongest momentum
+ EXHAUSTED = "exhausted" # extended from MA, volume fading
+ BREAKING = "breaking" # breaking below key MA (MA20)
+ DEAD = "dead" # below MA60, no recovery signal
+ RECOVERING = "recovering" # reclaiming MA20 after a break
+ UNKNOWN = "unknown" # insufficient data to classify
diff --git a/src/ashare_dp/domain/signal.py b/src/ashare_dp/domain/signal.py
new file mode 100644
index 0000000..ed5569a
--- /dev/null
+++ b/src/ashare_dp/domain/signal.py
@@ -0,0 +1,82 @@
+"""Signal domain objects — the core of Signal Intelligence.
+
+Signal × MarketState → Expectancy. This is the moat.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, date
+from enum import Enum
+from typing import Optional
+
+
+class SignalType(str, Enum):
+ """Known signal types. Each has its own detector and expectancy profile."""
+ # EMA52 family
+ EMA52_CROSS_UP = "ema52_cross_up"
+ EMA52_CROSS_DOWN = "ema52_cross_down"
+ EMA52_NEAR = "ema52_near"
+ # Vegas Channel family
+ VEGAS_LONG = "vegas_long" # price > EMA144, EMA12>EMA50>EMA144, pullback to EMA50
+ VEGAS_SHORT = "vegas_short" # price < EMA144, EMA12 yesterday's high (breakaway gap)
+ GAP_DOWN = "gap_down" # open < yesterday's low (breakaway gap)
+ # NR7 (Narrow Range 7)
+ NR7 = "nr7" # narrowest range in 7 days (coiling)
+ # Inside Bar
+ IB_LONG = "ib_long" # today's range inside yesterday's, close > mid
+ IB_SHORT = "ib_short" # today's range inside yesterday's, close < mid
+
+ @classmethod
+ def bullish_signals(cls) -> list["SignalType"]:
+ return [cls.EMA52_CROSS_UP, cls.VEGAS_LONG, cls.CHAN_1BUY, cls.ORB_UP]
+
+ @classmethod
+ def bearish_signals(cls) -> list["SignalType"]:
+ return [cls.EMA52_CROSS_DOWN, cls.VEGAS_SHORT, cls.CHAN_1SELL, cls.ORB_DOWN]
+
+
+@dataclass
+class SignalInstance:
+ """A single signal occurrence with its context and outcome.
+
+ Stored in signal_instance table. This is the training data
+ for the Expectancy Engine — every signal, its market state,
+ and what happened afterward.
+ """
+ # Identity
+ signal_type: str # SignalType value
+ ts_code: str # stock code
+ trade_date: date # when signal fired
+
+ # Signal details
+ signal_price: float = 0.0 # close price at signal
+
+ # Market context at signal time (state vector snapshot)
+ state_trend: float = 0.0
+ state_fear: float = 0.0
+ state_liquidity: float = 0.0
+ state_rotation: float = 0.0
+ state_participation: float = 0.0
+ state_volatility: float = 0.0
+ state_breadth: float = 0.0
+
+ # Outcome (forward-looking)
+ return_5d: Optional[float] = None # return after 5 trading days
+ return_10d: Optional[float] = None
+ return_20d: Optional[float] = None
+ max_return_5d: Optional[float] = None # max favorable excursion
+ max_drawdown_5d: Optional[float] = None # max adverse excursion
+ holding_days: Optional[int] = None # days until signal invalidates
+ outcome_known: bool = False # True if outcome data is complete
+
+ # Metadata
+ created_at: datetime = field(default_factory=datetime.now)
diff --git a/src/ashare_dp/domain/state.py b/src/ashare_dp/domain/state.py
new file mode 100644
index 0000000..5d0ab06
--- /dev/null
+++ b/src/ashare_dp/domain/state.py
@@ -0,0 +1,184 @@
+"""MarketState — the central domain object of the entire platform.
+
+Every downstream consumer (Dashboard, AI Agent, Backtester, Signal Engine)
+uses this same type. NOT a dict.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import date, datetime
+from typing import Optional
+
+
+@dataclass
+class MarketState:
+ """Continuous market state vector.
+
+ Each dimension is 0.0–1.0, representing the strength/intensity
+ of that market property. NOT a discrete classification.
+
+ Consumers read the vector directly; the human-readable label
+ is derived by the presentation layer only.
+ """
+
+ timestamp: datetime
+ trade_date: date
+ version: str = "1.0"
+ source: str = "ashare_dp"
+
+ # ── State dimensions (0.0–1.0 continuous) ──
+ trend: float = 0.0 # trend strength (breadth + MA alignment + resonance)
+ fear: float = 0.0 # fear/greed inverse (drawdown + range + decline breadth)
+ liquidity: float = 0.0 # liquidity health (volume vs avg + participation)
+ rotation: float = 0.0 # sector rotation intensity
+ participation: float = 0.0 # market participation breadth
+ volatility: float = 0.0 # normalized volatility
+ breadth: float = 0.0 # advance/decline health
+
+ # ── Quality metadata ──
+ confidence: float = 0.0 # how clearly the vector matches a regime (0–1)
+ quality: float = 0.0 # data quality / completeness score (0–1)
+
+ def to_dict(self) -> dict:
+ """Serialize to dict for JSON API responses."""
+ return {
+ "version": self.version,
+ "source": self.source,
+ "timestamp": self.timestamp.isoformat(),
+ "trade_date": self.trade_date.isoformat(),
+ "dimensions": {
+ "trend": self.trend,
+ "fear": self.fear,
+ "liquidity": self.liquidity,
+ "rotation": self.rotation,
+ "participation": self.participation,
+ "volatility": self.volatility,
+ "breadth": self.breadth,
+ },
+ "confidence": self.confidence,
+ "quality": self.quality,
+ }
+
+ def to_vector(self) -> list[float]:
+ """Return state as a plain float vector for ML / similarity search."""
+ return [
+ self.trend,
+ self.fear,
+ self.liquidity,
+ self.rotation,
+ self.participation,
+ self.volatility,
+ self.breadth,
+ ]
+
+ @property
+ def dimension_names(self) -> list[str]:
+ return [
+ "trend", "fear", "liquidity", "rotation",
+ "participation", "volatility", "breadth",
+ ]
+
+ @classmethod
+ def from_features(
+ cls,
+ features: dict,
+ trade_date: date,
+ timestamp: Optional[datetime] = None,
+ ) -> "MarketState":
+ """Construct MarketState from computed feature vectors.
+
+ Each feature category contributes to one or more state dimensions.
+ """
+ import time as _time
+ ts = timestamp or datetime.now()
+
+ # ── Breadth → breadth, participation ──
+ b = features.get("breadth_vector", {})
+ advance_ratio = b.get("advance_ratio", 0.5)
+ new_high_ratio = b.get("new_high_ratio", 0.0)
+ new_low_ratio = b.get("new_low_ratio", 0.0)
+
+ breadth_val = _clamp(advance_ratio, 0, 1)
+
+ # ── Volume → liquidity ──
+ v = features.get("volume_vector", {})
+ vs_5d = v.get("vs_5d", 1.0)
+ vs_20d = v.get("vs_20d", 1.0)
+ total_turnover = v.get("total_turnover", 0)
+
+ # Liquidity: volume relative to average, mapped to 0-1
+ # 1.0x avg → 0.5, 2.0x+ → 1.0, <0.5x → 0.0
+ liquidity_val = _clamp((min(vs_5d, vs_20d) - 0.5) / 1.5, 0, 1)
+
+ # ── Trend → trend ──
+ t = features.get("trend_vector", {})
+ above_ma20 = t.get("above_ma20_pct", 0.5)
+ resonance = t.get("resonance", 0.5)
+ divergence = t.get("divergence", 0.0)
+
+ # Trend: MA alignment + index resonance - divergence penalty
+ trend_val = _clamp(
+ above_ma20 * 0.5 + resonance * 0.4 - divergence * 0.3,
+ 0, 1,
+ )
+
+ # ── Fear → fear ──
+ f = features.get("fear_vector", {})
+ median_drawdown = abs(f.get("median_drawdown", 0.0))
+ pct_deep = f.get("pct_deep_drawdown", 0.0)
+ daily_range = f.get("daily_range_pct", 0.02)
+
+ # Fear: drawdown depth + breadth + intraday range
+ fear_val = _clamp(
+ median_drawdown * 2.0 + pct_deep * 1.5 + daily_range * 5.0,
+ 0, 1,
+ )
+
+ # ── Rotation → rotation ──
+ r = features.get("rotation_vector", {})
+ speed = r.get("speed", 0.0)
+ persistence = r.get("persistence", 0.5)
+
+ rotation_val = _clamp(speed * 0.6 + persistence * 0.4, 0, 1)
+
+ # ── Participation → participation ──
+ p = features.get("participation_vector", {})
+ pct_above_ma20 = p.get("pct_above_ma20", 0.5)
+ pct_above_ma60 = p.get("pct_above_ma60", 0.5)
+
+ participation_val = _clamp((pct_above_ma20 + pct_above_ma60) / 2, 0, 1)
+
+ # ── Volatility ──
+ vol_val = _clamp(daily_range / 0.06, 0, 1) # 6% daily range → 1.0
+
+ # ── Confidence: how internally consistent the vector is ──
+ # High trend + low fear → high confidence
+ # High trend + high fear → conflicting, lower confidence
+ consistency = 1.0 - abs(trend_val - (1.0 - fear_val)) * 0.5
+ confidence = _clamp(consistency * breadth_val, 0, 1)
+
+ # ── Quality: data completeness ──
+ quality = 1.0 # Phase 1: assume full quality; future: check for gaps
+
+ return cls(
+ timestamp=ts,
+ trade_date=trade_date,
+ trend=round(trend_val, 4),
+ fear=round(fear_val, 4),
+ liquidity=round(liquidity_val, 4),
+ rotation=round(rotation_val, 4),
+ participation=round(participation_val, 4),
+ volatility=round(vol_val, 4),
+ breadth=round(breadth_val, 4),
+ confidence=round(confidence, 4),
+ quality=round(quality, 4),
+ )
+
+
+def _clamp(value: float, lo: float, hi: float) -> float:
+ """Clamp value to [lo, hi], with NaN safety."""
+ import math
+ if value is None or (isinstance(value, float) and math.isnan(value)):
+ return (lo + hi) / 2
+ return max(lo, min(hi, value))
diff --git a/src/ashare_dp/domain/trade.py b/src/ashare_dp/domain/trade.py
new file mode 100644
index 0000000..3a66d5b
--- /dev/null
+++ b/src/ashare_dp/domain/trade.py
@@ -0,0 +1,46 @@
+"""Trade domain — recording actions and outcomes for system learning."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, date
+from typing import Optional
+
+
+@dataclass
+class TradeRecord:
+ """A single trade from entry to exit. The training data for system learning."""
+ # Identity
+ ts_code: str
+ trade_date: date
+
+ # Entry
+ entry_price: float
+ entry_time: datetime | None = None
+ signal_type: str = "" # which signal triggered the entry
+ position_pct: float = 0.0 # position size as % of portfolio
+
+ # Exit
+ exit_price: float | None = None
+ exit_time: datetime | None = None
+ exit_reason: str = "" # "target" | "stop" | "signal" | "time" | "manual"
+
+ # Outcome
+ return_pct: float | None = None # (exit - entry) / entry * 100
+ holding_days: int | None = None
+ max_favorable: float | None = None # best price during hold
+ max_adverse: float | None = None # worst price during hold
+
+ # Context (market state at entry)
+ state_trend: float = 0.0
+ state_fear: float = 0.0
+ state_liquidity: float = 0.0
+ state_breadth: float = 0.0
+
+ # Tags
+ tags: str = "" # comma-separated: "breakout,trend,momentum"
+ notes: str = ""
+
+ # Metadata
+ created_at: datetime = field(default_factory=datetime.now)
+ closed: bool = False
diff --git a/src/ashare_dp/execution/__init__.py b/src/ashare_dp/execution/__init__.py
new file mode 100644
index 0000000..1501668
--- /dev/null
+++ b/src/ashare_dp/execution/__init__.py
@@ -0,0 +1,6 @@
+"""EXECUTION INTELLIGENCE — how to trade: playbook, risk, brief assembly."""
+from ashare_dp.execution.playbook import build_playbook
+from ashare_dp.execution.risk import evaluate_risks
+from ashare_dp.execution.brief import build_brief
+
+__all__ = ["build_playbook", "evaluate_risks", "build_brief"]
diff --git a/src/ashare_dp/execution/brief.py b/src/ashare_dp/execution/brief.py
new file mode 100644
index 0000000..2fdafa5
--- /dev/null
+++ b/src/ashare_dp/execution/brief.py
@@ -0,0 +1,190 @@
+"""TradingBrief Builder — assembles TradingContext + serializes for API.
+
+NOT an engine. Pure assembly and serialization — no inference, no rules.
+The single place where TradingContext becomes API JSON.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from ashare_dp.domain.context import (
+ TradingContext, Playbook, Expectancy, Allocation,
+)
+from ashare_dp.domain.state import MarketState
+
+
+def build_brief(
+ trade_date: date,
+ state: MarketState,
+ playbook: Playbook,
+ expectancy: Expectancy | None,
+ leaders: dict,
+ opportunities: list,
+ money_flow,
+ allocation: Allocation | None,
+ narrative: str,
+ timestamp: datetime | None = None,
+) -> TradingContext:
+ """Assemble TradingContext from all engine outputs. Pure assembly."""
+ return TradingContext(
+ timestamp=timestamp or datetime.now(),
+ trade_date=trade_date,
+ state=state,
+ leaders=leaders,
+ opportunities=opportunities,
+ money_flow=money_flow,
+ playbook=playbook,
+ expectancy=expectancy,
+ allocation=allocation,
+ risk_budget=playbook.risk_budget if playbook else "Medium",
+ narrative=narrative,
+ )
+
+
+def generate_narrative(state, leaders, opportunities, flow_graph) -> str:
+ """Rule-based narrative. Template from features — not LLM."""
+ parts = []
+
+ t = state.trend
+ f = state.fear
+ if t > 0.5:
+ parts.append("市场处于趋势行情")
+ elif t > 0.3:
+ parts.append("市场震荡")
+ else:
+ parts.append("市场偏弱")
+
+ if flow_graph and flow_graph.flows:
+ top_flow = flow_graph.flows[0]
+ parts.append(f"资金从{top_flow.source}流向{top_flow.target}")
+
+ if opportunities:
+ top_opps = [o.name for o in opportunities[:3]]
+ parts.append(f"热点集中在{'、'.join(top_opps)}")
+
+ alive = [ind for ind, ld in leaders.items()
+ if isinstance(ld, dict) and ld.get("state") in ("leading", "growing", "birth")]
+ broken = [ind for ind, ld in leaders.items()
+ if isinstance(ld, dict) and ld.get("state") in ("dead", "breaking")]
+ if alive:
+ parts.append(f"龙头板块{'、'.join(alive[:3])}保持强势")
+ if broken:
+ parts.append(f"{'、'.join(broken[:3])}龙头走弱需回避")
+
+ if f > 0.5:
+ parts.append("市场恐慌情绪较高,注意风险控制")
+ elif f < 0.3:
+ parts.append("风险情绪可控")
+
+ return "。".join(parts) + "。"
+
+
+def brief_to_api_dict(
+ brief: TradingContext,
+ risk_events: list,
+ features: dict,
+ sentiment: dict,
+ all_expectancies: list | None = None,
+ theme_graph = None,
+ recommendations: list | None = None,
+) -> dict:
+ """Serialize TradingContext to the versioned API response.
+
+ The SINGLE serialization point. Router calls this, never builds JSON by hand.
+ Response schema is stable (version 1.0) — consumers depend on it.
+ """
+ pb = brief.playbook
+ exp = brief.expectancy
+ alloc = brief.allocation
+ mf = brief.money_flow
+
+ return {
+ "version": "1.0",
+ "timestamp": brief.timestamp.isoformat(),
+ "trade_date": brief.trade_date.isoformat(),
+ "playbook": {
+ "suitable_strategies": pb.suitable_strategies,
+ "unsuitable_strategies": pb.unsuitable_strategies,
+ "holding_time": pb.holding_time,
+ "risk_budget": pb.risk_budget,
+ "position_sizing": pb.position_sizing,
+ "bias": pb.bias,
+ "confidence": pb.confidence,
+ } if pb else {},
+ "expectancy": {
+ "signal_type": exp.signal_type,
+ "win_rate": exp.win_rate,
+ "avg_return": exp.avg_return,
+ "max_drawdown": exp.max_drawdown,
+ "avg_holding_days": exp.avg_holding_days,
+ "sample_count": exp.sample_count,
+ "similar_states": exp.similar_states,
+ "confidence": exp.confidence,
+ } if exp else {},
+ "allocation": {
+ "cash_pct": alloc.cash_pct,
+ "trend_pct": alloc.trend_pct,
+ "trial_pct": alloc.trial_pct,
+ "reasoning": alloc.reasoning,
+ } if alloc else {},
+ "opportunities": [
+ {
+ "name": o.name,
+ "score": o.score,
+ "lifecycle": o.lifecycle,
+ "persistence_days": o.persistence_days,
+ "leader_stock": o.leader_stock,
+ "top_stocks": o.top_stocks,
+ }
+ for o in (brief.opportunities or [])[:10]
+ ],
+ "money_flow": {
+ "flows": [
+ {"source": e.source, "target": e.target,
+ "magnitude": e.magnitude, "strength": e.strength}
+ for e in mf.flows
+ ] if mf else [],
+ "net_inflow": mf.net_inflow if mf else {},
+ "net_outflow": mf.net_outflow if mf else {},
+ },
+ "leaders": brief.leaders,
+ "risks": [e.to_dict() for e in risk_events],
+ "evidence": {
+ "state": brief.state.to_dict() if brief.state else {},
+ "features": features,
+ "sentiment": sentiment,
+ },
+ "theme_graph": {
+ "themes": [
+ {
+ "name": t.name,
+ "score": t.relevance_score,
+ "momentum": t.momentum,
+ "concepts": [
+ {"name": c.name, "score": c.relevance_score}
+ for c in (t.concepts or []) if c.relevance_score > 0
+ ],
+ }
+ for t in (theme_graph.themes if theme_graph else [])
+ if t.relevance_score > 0
+ ],
+ "edges": [
+ {"source": e.source, "target": e.target, "strength": e.strength}
+ for e in (theme_graph.edges if theme_graph else [])
+ ],
+ } if theme_graph else {},
+ "recommendations": recommendations or [],
+ "narrative": brief.narrative,
+ "all_expectancies": [
+ {
+ "signal_type": e.signal_type,
+ "win_rate": e.win_rate,
+ "avg_return": e.avg_return,
+ "max_drawdown": e.max_drawdown,
+ "sample_count": e.sample_count,
+ "confidence": e.confidence,
+ }
+ for e in (all_expectancies or [])
+ ],
+ }
diff --git a/src/ashare_dp/execution/playbook.py b/src/ashare_dp/execution/playbook.py
new file mode 100644
index 0000000..e8678f3
--- /dev/null
+++ b/src/ashare_dp/execution/playbook.py
@@ -0,0 +1,112 @@
+"""Playbook Engine — maps MarketState + Leaders + Opportunities to a complete trading playbook.
+
+Not a recommendation list. A complete playbook: what strategies work,
+what to avoid, how long to hold, how much to risk.
+"""
+
+from __future__ import annotations
+
+from ashare_dp.domain.context import Playbook
+from ashare_dp.domain.leadership import LeaderState
+from ashare_dp.domain.state import MarketState
+
+
+def build_playbook(
+ state: MarketState,
+ leaders: dict[str, dict],
+ opportunities: list,
+) -> Playbook:
+ """Build trading playbook from market state + leaders + opportunities.
+
+ Rules:
+ - High trend + low fear + high participation → Trend Following / Breakout
+ - Mid trend + high rotation → Sector Rotation
+ - Low trend + high fear → Defensive / Cash
+ - Leader state affects sector focus
+ """
+ t = state.trend
+ f = state.fear
+ l = state.liquidity
+ r = state.rotation
+ p = state.participation
+
+ suitable = []
+ unsuitable = []
+
+ # ── Strategy selection ──
+ if t > 0.55 and f < 0.4 and l > 0.5:
+ suitable.extend(["Trend Following", "Breakout", "Momentum"])
+ unsuitable.extend(["Mean Reversion", "Bottom Fishing"])
+ elif t > 0.4 and r > 0.4:
+ suitable.extend(["Sector Rotation", "Breakout"])
+ unsuitable.extend(["Counter Trend"])
+ elif t < 0.3 and f > 0.5:
+ suitable.append("Cash")
+ unsuitable.extend(["Trend Following", "Breakout", "Momentum", "Bottom Fishing"])
+ else:
+ suitable.extend(["Range Trading"])
+ unsuitable.extend(["Trend Following"])
+
+ if p < 0.3:
+ unsuitable.append("Heavy Position")
+
+ if l < 0.3:
+ unsuitable.append("Large Cap Breakout")
+
+ # ── Holding time ──
+ if t > 0.6 and l > 0.6:
+ holding_time = "3~5 Days"
+ elif t > 0.4:
+ holding_time = "2~4 Days"
+ else:
+ holding_time = "1~2 Days"
+
+ # ── Risk budget ──
+ if f > 0.6:
+ risk_budget = "Low"
+ elif f > 0.35:
+ risk_budget = "Medium"
+ else:
+ risk_budget = "High"
+
+ # ── Position sizing ──
+ if state.confidence > 0.7 and f < 0.3:
+ position_sizing = "Standard"
+ elif f > 0.5:
+ position_sizing = "Reduced"
+ else:
+ position_sizing = "Standard"
+
+ # ── Focus sectors ──
+ focus_sectors = []
+ avoid_sectors = []
+
+ for opp in opportunities[:5]:
+ if opp.score > 60:
+ focus_sectors.append(opp.name)
+
+ for ind, ld in leaders.items():
+ if isinstance(ld, dict) and ld.get("state") in (
+ LeaderState.DEAD.value, LeaderState.BREAKING.value
+ ):
+ avoid_sectors.append(ind)
+
+ # ── Bias ──
+ if t > 0.5 and f < 0.4:
+ bias = "LONG"
+ elif f > 0.6:
+ bias = "CASH"
+ else:
+ bias = "NEUTRAL"
+
+ return Playbook(
+ suitable_strategies=list(dict.fromkeys(suitable))[:4],
+ unsuitable_strategies=list(dict.fromkeys(unsuitable))[:4],
+ holding_time=holding_time,
+ risk_budget=risk_budget,
+ position_sizing=position_sizing,
+ focus_sectors=focus_sectors[:5],
+ avoid_sectors=avoid_sectors[:5],
+ bias=bias,
+ confidence=round(state.confidence, 4),
+ )
diff --git a/src/ashare_dp/execution/risk.py b/src/ashare_dp/execution/risk.py
new file mode 100644
index 0000000..0966ad9
--- /dev/null
+++ b/src/ashare_dp/execution/risk.py
@@ -0,0 +1,218 @@
+"""Risk Rule Engine — pluggable rules that emit RiskEvents.
+
+Each rule evaluates features independently. Adding a new risk
+means adding a new RiskRule — no engine code changes.
+Dashboard renders, AI explains, Backtest counts.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Callable
+
+from ashare_dp.domain.events import RiskEvent
+
+# Rule condition signature:
+# (features: dict) -> (triggered: bool, severity: float, payload: dict)
+
+
+class RiskRule:
+ """A single risk detection rule."""
+
+ def __init__(
+ self,
+ name: str,
+ event_type: str,
+ level: str, # "warning" | "danger"
+ affected: str,
+ condition: Callable[[dict], tuple[bool, float, dict]],
+ message_template: str,
+ ):
+ self.name = name
+ self.event_type = event_type
+ self.level = level
+ self.affected = affected
+ self.condition = condition
+ self.message_template = message_template
+
+
+# ── Rule definitions ──
+
+def _check_drawdown_spread(features: dict) -> tuple[bool, float, dict]:
+ """High-position stocks starting to drop broadly."""
+ fear = features.get("fear_vector", {})
+ pct_deep = fear.get("pct_deep_drawdown_20d", fear.get("pct_deep_drawdown", 0))
+ median_dd = abs(fear.get("median_drawdown_20d", fear.get("median_drawdown", 0)))
+
+ triggered = pct_deep > 0.30 or median_dd > 0.08
+ severity = min(max(pct_deep * 2 + median_dd * 5, 0), 1.0)
+ return triggered, severity, {
+ "pct_deep_drawdown": pct_deep,
+ "median_drawdown": median_dd,
+ }
+
+
+def _check_volume_drying(features: dict) -> tuple[bool, float, dict]:
+ """Volume drying up significantly vs. recent average."""
+ vol = features.get("volume_vector", {})
+ vs_5d = vol.get("vs_5d", 1.0)
+ vs_20d = vol.get("vs_20d", 1.0)
+
+ triggered = vs_5d < 0.80 or vs_20d < 0.75
+ severity = min(max((1.0 - min(vs_5d, vs_20d)) * 2, 0), 1.0)
+ return triggered, severity, {
+ "vs_5d": vs_5d,
+ "vs_20d": vs_20d,
+ }
+
+
+def _check_breadth_collapse(features: dict) -> tuple[bool, float, dict]:
+ """Market breadth collapsing — very few stocks advancing."""
+ breadth = features.get("breadth_vector", {})
+ advance_ratio = breadth.get("advance_ratio", 0.5)
+ down_prev = breadth.get("down_from_prev", 0)
+ up_prev = breadth.get("up_from_prev", 0)
+ total = breadth.get("advances", 0) + breadth.get("declines", 0) + breadth.get("unchanged", 0)
+
+ triggered = advance_ratio < 0.25
+ severity = min(max((0.5 - advance_ratio) * 2, 0), 1.0)
+
+ down_pct = round(down_prev / total * 100, 1) if total > 0 else 0
+ return triggered, severity, {
+ "advance_ratio": advance_ratio,
+ "down_pct": down_pct,
+ }
+
+
+def _check_high_volatility(features: dict) -> tuple[bool, float, dict]:
+ """Market volatility spiking — unusual daily ranges."""
+ fear = features.get("fear_vector", {})
+ avg_range = fear.get("daily_range_pct", fear.get("avg_daily_range", 0.02))
+
+ triggered = avg_range > 0.05
+ severity = min(max((avg_range - 0.03) / 0.05, 0), 1.0)
+ return triggered, severity, {
+ "avg_daily_range": avg_range,
+ }
+
+
+def _check_divergence(features: dict) -> tuple[bool, float, dict]:
+ """Indices diverging — some up, some down strongly."""
+ trend = features.get("trend_vector", {})
+ divergence = trend.get("divergence", 0.0)
+
+ triggered = divergence > 0.7
+ severity = min(divergence, 1.0)
+ return triggered, severity, {
+ "divergence": divergence,
+ }
+
+
+RISK_RULES: list[RiskRule] = [
+ RiskRule(
+ name="DrawdownSpread",
+ event_type="DrawdownSpread",
+ level="danger",
+ affected="All",
+ condition=_check_drawdown_spread,
+ message_template="高位股开始补跌:{pct_deep}% 的股票从高点回撤超 10%",
+ ),
+ RiskRule(
+ name="BreadthCollapse",
+ event_type="BreadthCollapse",
+ level="danger",
+ affected="All",
+ condition=_check_breadth_collapse,
+ message_template="上涨家数仅 {advance_pct}%,{down_pct}% 的股票下跌",
+ ),
+ RiskRule(
+ name="VolumeDrying",
+ event_type="VolumeDrying",
+ level="warning",
+ affected="All",
+ condition=_check_volume_drying,
+ message_template="成交额较5日均值下降 {vol_change}%",
+ ),
+ RiskRule(
+ name="HighVolatility",
+ event_type="HighVolatility",
+ level="warning",
+ affected="All",
+ condition=_check_high_volatility,
+ message_template="市场波动加剧,日均振幅达 {range_pct}%",
+ ),
+ RiskRule(
+ name="Divergence",
+ event_type="Divergence",
+ level="warning",
+ affected="All",
+ condition=_check_divergence,
+ message_template="指数走势分化,共振度低",
+ ),
+]
+
+
+def evaluate_risks(
+ features: dict[str, dict[str, Any]],
+ timestamp: datetime | None = None,
+) -> list[RiskEvent]:
+ """Evaluate all risk rules against current features.
+
+ Returns triggered RiskEvents sorted by severity descending.
+ """
+ if timestamp is None:
+ timestamp = datetime.now()
+
+ events: list[RiskEvent] = []
+
+ for rule in RISK_RULES:
+ try:
+ triggered, severity, payload = rule.condition(features)
+ except Exception:
+ continue # Skip rules that fail to evaluate
+
+ if not triggered:
+ continue
+
+ # Build human-readable message from template + payload
+ message = rule.message_template
+
+ # Build a formatting context with common derived values
+ fmt_ctx = {}
+ for k, v in payload.items():
+ if isinstance(v, float):
+ fmt_ctx[k] = round(v, 3)
+ else:
+ fmt_ctx[k] = v
+
+ # Add derived values for common templates
+ if "pct_deep_drawdown" in payload:
+ fmt_ctx["pct_deep"] = round(payload["pct_deep_drawdown"] * 100, 1)
+ if "pct_deep_drawdown_20d" in payload:
+ fmt_ctx["pct_deep"] = round(payload["pct_deep_drawdown_20d"] * 100, 1)
+ if "advance_ratio" in payload:
+ fmt_ctx["advance_pct"] = round(payload["advance_ratio"] * 100, 1)
+ if "vs_5d" in payload:
+ fmt_ctx["vol_change"] = round((1 - payload["vs_5d"]) * 100, 1)
+ if "avg_daily_range" in payload:
+ fmt_ctx["range_pct"] = round(payload["avg_daily_range"] * 100, 1)
+
+ try:
+ message = message.format(**fmt_ctx)
+ except (KeyError, ValueError):
+ pass # Keep template as-is if formatting fails
+
+ events.append(RiskEvent(
+ type=rule.event_type,
+ severity=round(severity, 4),
+ level=rule.level,
+ affected=rule.affected,
+ confidence=round(severity, 4), # Phase 1: severity ≈ confidence
+ message=message,
+ payload=payload,
+ timestamp=timestamp,
+ ))
+
+ # Sort: danger first, then by severity desc
+ events.sort(key=lambda e: (0 if e.level == "danger" else 1, -e.severity))
+ return events
diff --git a/src/ashare_dp/features/__init__.py b/src/ashare_dp/features/__init__.py
new file mode 100644
index 0000000..488f167
--- /dev/null
+++ b/src/ashare_dp/features/__init__.py
@@ -0,0 +1,21 @@
+"""Feature Store — centralized market feature computation.
+
+All feature modules register themselves with the FeatureRegistry on import.
+Engine code imports only the registry, never individual feature functions.
+
+Usage:
+ from ashare_dp.features import registry
+ features = registry.compute_all(db, trade_date)
+"""
+
+from ashare_dp.features.registry import registry
+
+# Import feature modules to trigger registration
+from ashare_dp.features import breadth # noqa: F401
+from ashare_dp.features import volume # noqa: F401
+from ashare_dp.features import trend # noqa: F401
+from ashare_dp.features import fear # noqa: F401
+from ashare_dp.features import rotation # noqa: F401
+from ashare_dp.features import participation # noqa: F401
+
+__all__ = ["registry"]
diff --git a/src/ashare_dp/features/breadth.py b/src/ashare_dp/features/breadth.py
new file mode 100644
index 0000000..55d03aa
--- /dev/null
+++ b/src/ashare_dp/features/breadth.py
@@ -0,0 +1,151 @@
+"""Breadth features: advance/decline, new highs/lows, up/down volume ratio."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import duckdb
+
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.features.registry import registry
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def _compute_breadth_vector(db: Any, trade_date: date, _results: dict) -> dict[str, Any]:
+ """Compute market breadth metrics for a single trading day.
+
+ Uses DuckDB to aggregate over ALL stocks' daily K-line in one query.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ # Single query: advance/decline, new highs/lows, up/down volume
+ sql = f"""
+ WITH latest AS (
+ SELECT *, ROW_NUMBER() OVER (
+ PARTITION BY ts_code ORDER BY trade_time DESC
+ ) as rn
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date <= $trade_date
+ ),
+ today AS (
+ SELECT * FROM latest WHERE rn = 1
+ ),
+ prev AS (
+ SELECT * FROM latest WHERE rn = 2
+ ),
+ with_prev AS (
+ SELECT
+ t.ts_code,
+ t.close AS close_today,
+ t.open AS open_today,
+ t.volume AS vol_today,
+ t.amount AS amt_today,
+ t.high AS high_today,
+ t.low AS low_today,
+ p.close AS close_prev,
+ -- 20-day and 60-day rolling high
+ MAX(t.close) OVER (
+ PARTITION BY t.ts_code
+ ORDER BY t.trade_date
+ ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
+ ) AS high_20d,
+ MAX(t.close) OVER (
+ PARTITION BY t.ts_code
+ ORDER BY t.trade_date
+ ROWS BETWEEN 59 PRECEDING AND CURRENT ROW
+ ) AS high_60d,
+ MIN(t.close) OVER (
+ PARTITION BY t.ts_code
+ ORDER BY t.trade_date
+ ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
+ ) AS low_20d,
+ MIN(t.close) OVER (
+ PARTITION BY t.ts_code
+ ORDER BY t.trade_date
+ ROWS BETWEEN 59 PRECEDING AND CURRENT ROW
+ ) AS low_60d
+ FROM today t
+ LEFT JOIN prev p ON t.ts_code = p.ts_code
+ )
+ SELECT
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE close_today > open_today) AS advances,
+ COUNT(*) FILTER (WHERE close_today < open_today) AS declines,
+ COUNT(*) FILTER (WHERE close_today = open_today) AS unchanged,
+ COUNT(*) FILTER (WHERE close_today > close_prev AND close_prev IS NOT NULL) AS up_from_prev,
+ COUNT(*) FILTER (WHERE close_today < close_prev AND close_prev IS NOT NULL) AS down_from_prev,
+ COUNT(*) FILTER (WHERE close_today >= high_20d * 0.98) AS near_20d_high,
+ COUNT(*) FILTER (WHERE close_today <= low_20d * 1.02) AS near_20d_low,
+ COUNT(*) FILTER (WHERE close_today >= high_60d * 0.98) AS near_60d_high,
+ COUNT(*) FILTER (WHERE close_today <= low_60d * 1.02) AS near_60d_low,
+ SUM(vol_today) AS total_volume,
+ SUM(amt_today) AS total_amount,
+ SUM(CASE WHEN close_today > open_today THEN vol_today ELSE 0 END) AS up_volume,
+ SUM(CASE WHEN close_today < open_today THEN vol_today ELSE 0 END) AS down_volume,
+ AVG((close_today - open_today) / NULLIF(open_today, 0) * 100) AS avg_pct_chg,
+ AVG((high_today - low_today) / NULLIF(close_today, 0)) AS avg_daily_range
+ FROM with_prev
+ """
+ result = conn.execute(sql, {"trade_date": trade_date.isoformat()}).fetchone()
+ finally:
+ conn.close()
+
+ if result is None or result[0] == 0:
+ return {
+ "advance_ratio": 0.5,
+ "advances": 0,
+ "declines": 0,
+ "unchanged": 0,
+ "up_from_prev": 0,
+ "down_from_prev": 0,
+ "new_high_ratio": 0.0,
+ "new_low_ratio": 0.0,
+ "up_vol_ratio": 0.5,
+ "total_volume": 0,
+ "total_amount": 0,
+ "avg_pct_chg": 0.0,
+ "avg_daily_range": 0.02,
+ }
+
+ total = result[0]
+ advances = result[1]
+ declines = result[2]
+ unchanged = result[3]
+ up_prev = result[4]
+ down_prev = result[5]
+ near_20h = result[6]
+ near_20l = result[7]
+ near_60h = result[8]
+ near_60l = result[9]
+ total_vol = result[10] or 0
+ total_amt = result[11] or 0
+ up_vol = result[12] or 0
+ down_vol = result[13] or 0
+
+ return {
+ "advance_ratio": round(advances / total, 4) if total else 0.5,
+ "advances": advances,
+ "declines": declines,
+ "unchanged": unchanged,
+ "up_from_prev": up_prev,
+ "down_from_prev": down_prev,
+ "new_high_ratio": round(max(near_20h, near_60h) / total, 4) if total else 0.0,
+ "new_low_ratio": round(max(near_20l, near_60l) / total, 4) if total else 0.0,
+ "up_vol_ratio": round(up_vol / (up_vol + down_vol), 4) if (up_vol + down_vol) > 0 else 0.5,
+ "total_volume": int(total_vol),
+ "total_amount": float(total_amt),
+ "avg_pct_chg": round(float(result[14] or 0), 4),
+ "avg_daily_range": round(float(result[15] or 0.02), 4),
+ }
+
+
+registry.register(FeatureDefinition(
+ name="breadth_vector",
+ category="breadth",
+ description="Advance/decline ratio, new high/low ratios, up/down volume ratio, total turnover",
+ dependencies=[],
+ compute=_compute_breadth_vector,
+))
diff --git a/src/ashare_dp/features/fear.py b/src/ashare_dp/features/fear.py
new file mode 100644
index 0000000..340e860
--- /dev/null
+++ b/src/ashare_dp/features/fear.py
@@ -0,0 +1,137 @@
+"""Fear features: drawdown depth, breadth of deep drawdowns, intraday range."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import duckdb
+
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.features.registry import registry
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def _compute_fear_vector(db: Any, trade_date: date, _results: dict) -> dict[str, Any]:
+ """Compute fear/greed metrics: drawdown distribution, intraday volatility.
+
+ Uses a single DuckDB window query to compute drawdown from 20d and 60d
+ highs for all stocks, then aggregates the distribution.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ sql = f"""
+ WITH ranked AS (
+ SELECT *,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time DESC) as rn_desc,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time ASC) as rn_asc
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $start_date
+ ),
+ latest AS (
+ SELECT * FROM ranked WHERE rn_desc = 1
+ ),
+ -- 20-day high per stock
+ high20 AS (
+ SELECT ts_code, MAX(high) as h20
+ FROM ranked
+ WHERE rn_desc <= 20
+ GROUP BY ts_code
+ ),
+ -- 60-day high per stock
+ high60 AS (
+ SELECT ts_code, MAX(high) as h60
+ FROM ranked
+ WHERE rn_desc <= 60
+ GROUP BY ts_code
+ ),
+ -- Join with latest
+ combined AS (
+ SELECT
+ l.ts_code, l.close, l.high, l.low, l.open,
+ h20.h20, h60.h60,
+ (l.high - l.low) / NULLIF(l.close, 0) AS daily_range
+ FROM latest l
+ JOIN high20 h20 ON l.ts_code = h20.ts_code
+ JOIN high60 h60 ON l.ts_code = h60.ts_code
+ ),
+ drawdowns AS (
+ SELECT *,
+ (close - h20) / NULLIF(h20, 0) AS dd_20d,
+ (close - h60) / NULLIF(h60, 0) AS dd_60d
+ FROM combined
+ )
+ SELECT
+ PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY dd_20d) AS dd20_p25,
+ PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY dd_20d) AS dd20_p50,
+ PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY dd_20d) AS dd20_p75,
+ PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY dd_60d) AS dd60_p25,
+ PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY dd_60d) AS dd60_p50,
+ PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY dd_60d) AS dd60_p75,
+ COUNT(*) FILTER (WHERE dd_20d < -0.10) AS deep_dd_20_count,
+ COUNT(*) FILTER (WHERE dd_60d < -0.20) AS deep_dd_60_count,
+ COUNT(*) AS total,
+ PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY daily_range) AS range_p25,
+ PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY daily_range) AS range_p50,
+ PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY daily_range) AS range_p75,
+ AVG(daily_range) AS avg_daily_range
+ FROM drawdowns
+ WHERE h20 IS NOT NULL
+ """
+ # Look back ~60 trading days ≈ 90 calendar days
+ start_date = trade_date.strftime("%Y-%m-%d")
+ # Use a window that covers at least 60 trading days
+ result = conn.execute(
+ sql.replace("$start_date", f"'{_date_sub_calendar(trade_date, 95)}'")
+ ).fetchone()
+ finally:
+ conn.close()
+
+ if result is None or result[8] == 0:
+ return _empty_fear()
+
+ total = int(result[8])
+
+ return {
+ "median_drawdown_20d": round(float(result[1] or 0), 4),
+ "median_drawdown_60d": round(float(result[4] or 0), 4),
+ "pct_deep_drawdown_20d": round(int(result[6]) / total, 4),
+ "pct_deep_drawdown_60d": round(int(result[7]) / total, 4),
+ # Legacy keys for backward compat with MarketState.from_features()
+ "median_drawdown": round(float(result[1] or 0), 4),
+ "pct_deep_drawdown": round(int(result[6]) / total, 4),
+ "daily_range_pct": round(float(result[12] or 0.02), 4),
+ "daily_range_p50": round(float(result[10] or 0.02), 4),
+ "total_stocks": total,
+ }
+
+
+def _date_sub_calendar(trade_date: date, days: int) -> str:
+ """Approximate calendar date N days before trade_date."""
+ from datetime import timedelta
+ return (trade_date - timedelta(days=days)).strftime("%Y-%m-%d")
+
+
+def _empty_fear() -> dict:
+ return {
+ "median_drawdown_20d": 0.0,
+ "median_drawdown_60d": 0.0,
+ "pct_deep_drawdown_20d": 0.0,
+ "pct_deep_drawdown_60d": 0.0,
+ "median_drawdown": 0.0,
+ "pct_deep_drawdown": 0.0,
+ "daily_range_pct": 0.02,
+ "daily_range_p50": 0.02,
+ "total_stocks": 0,
+ }
+
+
+registry.register(FeatureDefinition(
+ name="fear_vector",
+ category="fear",
+ description="Drawdown distribution (20d/60d), deep drawdown breadth, intraday range",
+ dependencies=[],
+ compute=_compute_fear_vector,
+))
diff --git a/src/ashare_dp/features/participation.py b/src/ashare_dp/features/participation.py
new file mode 100644
index 0000000..403eaf9
--- /dev/null
+++ b/src/ashare_dp/features/participation.py
@@ -0,0 +1,93 @@
+"""Participation features: breadth of market participation."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import duckdb
+
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.features.registry import registry
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def _compute_participation_vector(db: Any, trade_date: date, _results: dict) -> dict[str, Any]:
+ """Compute market participation: % stocks above key MAs, volume participation rate."""
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ sql = f"""
+ WITH ranked AS (
+ SELECT *,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time DESC) as rn_desc
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date <= $trade_date
+ ),
+ with_ma AS (
+ SELECT
+ ts_code,
+ close,
+ volume,
+ 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,
+ rn_desc
+ FROM ranked
+ ),
+ latest AS (
+ SELECT * FROM with_ma WHERE rn_desc = 1
+ )
+ SELECT
+ COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE close > ma20) AS above_ma20,
+ COUNT(*) FILTER (WHERE close > ma60) AS above_ma60,
+ COUNT(*) FILTER (WHERE volume > vol_ma20 AND vol_ma20 > 0) AS above_vol_ma20,
+ AVG(CASE WHEN vol_ma20 > 0 THEN volume / vol_ma20 ELSE NULL END) AS avg_vol_ratio
+ FROM latest
+ """
+ result = conn.execute(sql, {"trade_date": trade_date.isoformat()}).fetchone()
+ finally:
+ conn.close()
+
+ if result is None or result[0] == 0:
+ return _empty_participation()
+
+ total = int(result[0])
+
+ return {
+ "pct_above_ma20": round(int(result[1]) / total, 4),
+ "pct_above_ma60": round(int(result[2]) / total, 4),
+ "pct_above_vol_ma20": round(int(result[3]) / total, 4),
+ "avg_vol_ratio": round(float(result[4] or 1.0), 4),
+ "total_stocks": total,
+ }
+
+
+def _empty_participation() -> dict:
+ return {
+ "pct_above_ma20": 0.5,
+ "pct_above_ma60": 0.5,
+ "pct_above_vol_ma20": 0.5,
+ "avg_vol_ratio": 1.0,
+ "total_stocks": 0,
+ }
+
+
+registry.register(FeatureDefinition(
+ name="participation_vector",
+ category="participation",
+ description="% stocks above MA20/MA60, volume participation rate",
+ dependencies=[],
+ compute=_compute_participation_vector,
+))
diff --git a/src/ashare_dp/features/registry.py b/src/ashare_dp/features/registry.py
new file mode 100644
index 0000000..ae54285
--- /dev/null
+++ b/src/ashare_dp/features/registry.py
@@ -0,0 +1,128 @@
+"""Feature Registry — centralized management of all market features.
+
+Engine code only knows about the registry; individual feature functions
+are looked up by name. Adding a new feature = register it, no engine changes.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any, Callable
+
+from ashare_dp.domain.features import FeatureDefinition
+
+
+class FeatureRegistry:
+ """Central registry for all market features.
+
+ Usage:
+ from ashare_dp.features.registry import registry
+
+ # Register a feature
+ registry.register(FeatureDefinition(
+ name="breadth_vector",
+ category="breadth",
+ description="Advance/decline ratio and related breadth metrics",
+ dependencies=[],
+ compute=_compute_breadth_vector,
+ ))
+
+ # Compute all features for a date
+ features = registry.compute_all(db, trade_date)
+
+ # Get features by category
+ breadth_features = registry.get_by_category("breadth")
+ """
+
+ def __init__(self):
+ self._features: dict[str, FeatureDefinition] = {}
+
+ def register(self, feature: FeatureDefinition) -> None:
+ """Register a feature definition. Overwrites if name exists."""
+ self._features[feature.name] = feature
+
+ def get(self, name: str) -> FeatureDefinition | None:
+ """Get a feature definition by name."""
+ return self._features.get(name)
+
+ def get_by_category(self, category: str) -> list[FeatureDefinition]:
+ """Get all features in a category."""
+ return [f for f in self._features.values() if f.category == category]
+
+ def compute_all(self, db: Any, trade_date: date) -> dict[str, dict[str, Any]]:
+ """Compute all registered features for a given trading date.
+
+ Args:
+ db: Database instance (read_only DuckDB connection).
+ trade_date: The trading date to compute features for.
+
+ Returns:
+ Dict mapping feature name → feature value dict.
+ Features are computed in dependency order (simple topological sort).
+ """
+ results: dict[str, dict[str, Any]] = {}
+ computed: set[str] = set()
+ pending: set[str] = set(self._features.keys())
+
+ while pending:
+ ready = [
+ name for name in pending
+ if all(dep in computed for dep in self._features[name].dependencies)
+ ]
+ if not ready:
+ # Circular dependency or missing dependency
+ remaining = ", ".join(sorted(pending))
+ raise RuntimeError(
+ f"Cannot resolve feature dependencies. "
+ f"Remaining: {remaining}. Computed: {computed}"
+ )
+
+ for name in ready:
+ feature = self._features[name]
+ if feature.compute is not None:
+ results[name] = feature.compute(db, trade_date, results)
+ computed.add(name)
+ pending.discard(name)
+
+ return results
+
+ def compute_category(
+ self, db: Any, trade_date: date, category: str
+ ) -> dict[str, dict[str, Any]]:
+ """Compute all features in a specific category."""
+ features = self.get_by_category(category)
+ # Compute dependencies first
+ all_needed: set[str] = set()
+ for f in features:
+ all_needed.add(f.name)
+ all_needed.update(f.dependencies)
+
+ full_results = self.compute_all(db, trade_date)
+ return {k: v for k, v in full_results.items() if k in all_needed}
+
+ def list_categories(self) -> list[str]:
+ """List all unique feature categories."""
+ return sorted({f.category for f in self._features.values()})
+
+ def list_features(self) -> list[dict]:
+ """List all registered features with metadata."""
+ return [
+ {
+ "name": f.name,
+ "category": f.category,
+ "description": f.description,
+ "dependencies": f.dependencies,
+ "version": f.version,
+ }
+ for f in sorted(self._features.values(), key=lambda x: x.name)
+ ]
+
+ def __len__(self) -> int:
+ return len(self._features)
+
+ def __contains__(self, name: str) -> bool:
+ return name in self._features
+
+
+# Module-level singleton
+registry = FeatureRegistry()
diff --git a/src/ashare_dp/features/rotation.py b/src/ashare_dp/features/rotation.py
new file mode 100644
index 0000000..1c354ab
--- /dev/null
+++ b/src/ashare_dp/features/rotation.py
@@ -0,0 +1,316 @@
+"""Rotation features: sector rotation speed, persistence, concentration.
+
+Also computes the RotationGraph (nodes + edges) from industry turnover changes.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import duckdb
+
+from ashare_dp.domain.events import RotationEdge, RotationGraph
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.features.registry import registry
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def _compute_rotation_vector(db: Any, trade_date: date, _results: dict) -> dict[str, Any]:
+ """Compute rotation intensity metrics.
+
+ Measures how fast money is rotating between industries and how
+ persistent the current leaders are.
+ """
+ # Use the compute_rotation_graph result for rotation metrics
+ rg = compute_rotation_graph_from_db(db, trade_date, lookback=20)
+ momentum = compute_industry_momentum_from_db(db, trade_date)
+
+ if not rg.edges:
+ return _empty_rotation()
+
+ # Speed: average edge strength (higher = faster rotation)
+ speed = sum(e.strength for e in rg.edges) / len(rg.edges) if rg.edges else 0.0
+
+ # Persistence: fraction of top-5 industries today that were also top-5 5 days ago
+ # (simplified: use the strongest edge's persistence_days)
+ persistence = max((e.persistence_days / 10 for e in rg.edges), default=0.0)
+ persistence = min(persistence, 1.0)
+
+ # Concentration: how concentrated are flows in the top 3 edges?
+ if len(rg.edges) >= 3:
+ top3 = sum(e.strength for e in rg.edges[:3])
+ total = sum(e.strength for e in rg.edges)
+ concentration = top3 / total if total > 0 else 0.5
+ else:
+ concentration = 0.5
+
+ return {
+ "speed": round(speed, 4),
+ "persistence": round(persistence, 4),
+ "concentration": round(concentration, 4),
+ "edge_count": len(rg.edges),
+ "node_count": len(rg.nodes),
+ }
+
+
+def compute_rotation_graph_from_db(
+ db: Any, trade_date: date, lookback: int = 20
+) -> RotationGraph:
+ """Build rotation graph from industry-level turnover changes.
+
+ Uses stock_industry table joined with daily K-line to compute
+ per-industry turnover trends, then generates directed edges
+ where money flows from declining industries to rising ones.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ # Get industries and their recent turnover trends
+ sql = f"""
+ WITH daily AS (
+ SELECT
+ k.ts_code AS ts_code,
+ k.trade_date,
+ k.amount,
+ si.industry_name
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true) k
+ JOIN stock_industry si ON k.ts_code = si.ts_code
+ WHERE k.trade_date >= $start_date AND k.trade_date <= $end_date
+ ),
+ industry_daily AS (
+ SELECT
+ industry_name,
+ trade_date,
+ SUM(amount) AS total_amount,
+ COUNT(*) AS stock_count
+ FROM daily
+ GROUP BY industry_name, trade_date
+ ),
+ -- Recent (last 5 trading days) vs prior (5 trading days before that)
+ 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_amount,
+ SUM(stock_count) as recent_count
+ FROM ranked WHERE rn <= 5
+ GROUP BY industry_name
+ ),
+ prior AS (
+ SELECT industry_name, SUM(total_amount) as prior_amount
+ FROM ranked WHERE rn > 5 AND rn <= 10
+ GROUP BY industry_name
+ ),
+ changes AS (
+ SELECT
+ COALESCE(r.industry_name, p.industry_name) AS industry_name,
+ COALESCE(r.recent_amount, 0) AS recent_amount,
+ COALESCE(p.prior_amount, 0) AS prior_amount,
+ CASE
+ WHEN COALESCE(p.prior_amount, 0) > 0
+ THEN (COALESCE(r.recent_amount, 0) - p.prior_amount) / p.prior_amount
+ ELSE 0
+ END AS turnover_change,
+ COALESCE(r.recent_count, 0) AS stock_count
+ FROM recent r
+ FULL OUTER JOIN prior p ON r.industry_name = p.industry_name
+ WHERE COALESCE(r.recent_amount, 0) + COALESCE(p.prior_amount, 0) > 0
+ )
+ SELECT
+ industry_name,
+ recent_amount,
+ prior_amount,
+ turnover_change,
+ stock_count
+ FROM changes
+ ORDER BY turnover_change DESC
+ """
+ start_str = (trade_date - __timedelta(lookback + 10)).strftime("%Y-%m-%d")
+ end_str = trade_date.strftime("%Y-%m-%d")
+
+ result = conn.execute(
+ sql.replace("$start_date", f"'{start_str}'").replace("$end_date", f"'{end_str}'")
+ ).fetchdf()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return RotationGraph(nodes=[], edges=[])
+
+ industries = result["industry_name"].tolist()
+ changes = result.set_index("industry_name")["turnover_change"].to_dict()
+
+ # Generate edges: from declining industries to rising ones
+ edges = []
+ gainers = sorted(
+ [(k, v) for k, v in changes.items() if v > 0.05],
+ key=lambda x: x[1], reverse=True,
+ )
+ losers = sorted(
+ [(k, v) for k, v in changes.items() if v < -0.05],
+ key=lambda x: x[1],
+ )
+
+ # Create edges from top losers to top gainers
+ max_edges = min(len(gainers), len(losers), 5)
+ for i in range(max_edges):
+ los_name, los_change = losers[i]
+ win_name, win_change = gainers[i]
+ strength = min((win_change - los_change) / 2.0, 1.0)
+ edges.append(RotationEdge(
+ source=los_name,
+ target=win_name,
+ strength=round(strength, 4),
+ confidence=round(min(abs(win_change), abs(los_change)) * 2, 4),
+ source_change=round(los_change, 4),
+ target_change=round(win_change, 4),
+ persistence_days=1, # Phase 1: simplified
+ ))
+
+ return RotationGraph(
+ nodes=sorted(industries),
+ edges=sorted(edges, key=lambda e: e.strength, reverse=True),
+ )
+
+
+def compute_industry_momentum_from_db(
+ db: Any, trade_date: date, windows: list[int] | None = None
+) -> list[dict]:
+ """Compute per-industry momentum across multiple time windows.
+
+ Returns list of dicts with turnover change % for 5d, 10d, 20d windows.
+ """
+ if windows is None:
+ windows = [5, 10, 20]
+
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ code_list = "', '".join(
+ f"'{c}'" for c in ["主板", "创业板", "科创板", "北交所"]
+ )
+ # Phase 1: use derived market as industry proxy if stock_industry is empty
+ # Check if stock_industry has data
+ check_sql = "SELECT COUNT(*) FROM stock_industry"
+ has_industry = conn.execute(check_sql).fetchone()[0] > 0
+
+ if has_industry:
+ join_clause = "JOIN stock_industry si ON k.ts_code = si.ts_code"
+ group_col = "si.industry_name"
+ else:
+ # Fallback: derive market from ts_code
+ join_clause = ""
+ group_col = """
+ CASE
+ WHEN k.ts_code LIKE '6%SH' OR k.ts_code LIKE '0%SZ' THEN '主板'
+ WHEN k.ts_code LIKE '3%SZ' THEN '创业板'
+ WHEN k.ts_code LIKE '688%SH' THEN '科创板'
+ WHEN k.ts_code LIKE '4%BJ' OR k.ts_code LIKE '8%BJ' OR k.ts_code LIKE '92%BJ' THEN '北交所'
+ ELSE '其他'
+ END
+ """
+
+ sql = f"""
+ WITH daily AS (
+ SELECT
+ k.ts_code AS ts_code,
+ k.trade_date,
+ k.amount,
+ {group_col} AS segment
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true) k
+ {join_clause}
+ WHERE k.trade_date >= $start_date AND k.trade_date <= $end_date
+ ),
+ segment_daily AS (
+ SELECT
+ segment,
+ trade_date,
+ SUM(amount) AS total_amount,
+ COUNT(DISTINCT ts_code) AS stock_count
+ FROM daily
+ GROUP BY segment, trade_date
+ )
+ SELECT
+ segment,
+ MAX(trade_date) AS latest_date,
+ AVG(CASE WHEN trade_date >= $recent_5 THEN total_amount END) AS avg_5d,
+ AVG(CASE WHEN trade_date < $recent_5 AND trade_date >= $prior_5 THEN total_amount END) AS avg_prior_5d,
+ AVG(CASE WHEN trade_date >= $recent_10 THEN total_amount END) AS avg_10d,
+ AVG(CASE WHEN trade_date < $recent_10 AND trade_date >= $prior_10 THEN total_amount END) AS avg_prior_10d,
+ MAX(stock_count) AS stock_count
+ FROM segment_daily
+ GROUP BY segment
+ ORDER BY avg_5d DESC
+ """
+ end_str = trade_date.strftime("%Y-%m-%d")
+ # Approximate trading day offsets
+ from datetime import timedelta
+ recent_5 = (trade_date - timedelta(days=7)).strftime("%Y-%m-%d")
+ prior_5 = (trade_date - timedelta(days=14)).strftime("%Y-%m-%d")
+ recent_10 = (trade_date - timedelta(days=14)).strftime("%Y-%m-%d")
+ prior_10 = (trade_date - timedelta(days=28)).strftime("%Y-%m-%d")
+
+ result = conn.execute(
+ sql
+ .replace("$start_date", f"'{prior_10}'")
+ .replace("$end_date", f"'{end_str}'")
+ .replace("$recent_5", f"'{recent_5}'")
+ .replace("$prior_5", f"'{prior_5}'")
+ .replace("$recent_10", f"'{recent_10}'")
+ .replace("$prior_10", f"'{prior_10}'")
+ ).fetchdf()
+ finally:
+ conn.close()
+
+ industries = []
+ for _, row in result.iterrows():
+ avg_5 = float(row["avg_5d"] or 0)
+ avg_p5 = float(row["avg_prior_5d"] or 0)
+ avg_10 = float(row["avg_10d"] or 0)
+ avg_p10 = float(row["avg_prior_10d"] or 0)
+
+ chg_5d = round((avg_5 - avg_p5) / avg_p5, 4) if avg_p5 > 0 else 0.0
+ chg_10d = round((avg_10 - avg_p10) / avg_p10, 4) if avg_p10 > 0 else 0.0
+
+ industries.append({
+ "segment": row["segment"],
+ "stock_count": int(row["stock_count"]),
+ "turnover_change_5d": chg_5d,
+ "turnover_change_10d": chg_10d,
+ })
+
+ return industries
+
+
+def _empty_rotation() -> dict:
+ return {
+ "speed": 0.0,
+ "persistence": 0.5,
+ "concentration": 0.5,
+ "edge_count": 0,
+ "node_count": 0,
+ }
+
+
+def __timedelta(days: int):
+ from datetime import timedelta
+ return timedelta(days=days)
+
+
+# Exported for use by router
+compute_rotation_graph = compute_rotation_graph_from_db
+compute_industry_momentum = compute_industry_momentum_from_db
+
+
+registry.register(FeatureDefinition(
+ name="rotation_vector",
+ category="rotation",
+ description="Sector rotation speed, persistence, concentration",
+ dependencies=[],
+ compute=_compute_rotation_vector,
+))
diff --git a/src/ashare_dp/features/trend.py b/src/ashare_dp/features/trend.py
new file mode 100644
index 0000000..61ae21f
--- /dev/null
+++ b/src/ashare_dp/features/trend.py
@@ -0,0 +1,179 @@
+"""Trend features: index MA positioning, resonance/divergence, trend strength."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any
+
+import duckdb
+
+from ashare_dp.core.models import INDEX_CODES
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.features.registry import registry
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+# Sina symbol format for indices used in Parquet ts_code
+# The index codes stored in Parquet use format like "000001.SH"
+INDEX_TS_CODES = list(INDEX_CODES.keys())
+
+
+def _compute_trend_vector(db: Any, trade_date: date, _results: dict) -> dict[str, Any]:
+ """Compute trend metrics from index daily K-line data.
+
+ Analyzes 7 major indices for MA positioning, slope, and mutual alignment.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ code_list = "', '".join(INDEX_TS_CODES)
+
+ try:
+ sql = f"""
+ WITH idx_data AS (
+ SELECT
+ ts_code,
+ trade_date,
+ trade_time,
+ close,
+ volume,
+ AVG(close) OVER (
+ PARTITION BY ts_code ORDER BY trade_time
+ ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
+ ) AS ma5,
+ AVG(close) OVER (
+ PARTITION BY ts_code ORDER BY trade_time
+ ROWS BETWEEN 9 PRECEDING AND CURRENT ROW
+ ) AS ma10,
+ 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,
+ ROW_NUMBER() OVER (
+ PARTITION BY ts_code ORDER BY trade_time DESC
+ ) as rn
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE ts_code IN ('{code_list}')
+ ),
+ latest AS (
+ SELECT * FROM idx_data WHERE rn = 1
+ ),
+ prev AS (
+ SELECT * FROM idx_data WHERE rn = 2
+ )
+ SELECT
+ l.ts_code,
+ l.close,
+ l.ma5, l.ma10, l.ma20, l.ma60,
+ l.volume,
+ l.vol_ma20,
+ p.close AS prev_close,
+ -- price vs MAs
+ CASE WHEN l.close > l.ma5 THEN 1 ELSE 0 END AS above_ma5,
+ CASE WHEN l.close > l.ma10 THEN 1 ELSE 0 END AS above_ma10,
+ CASE WHEN l.close > l.ma20 THEN 1 ELSE 0 END AS above_ma20,
+ CASE WHEN l.close > l.ma60 THEN 1 ELSE 0 END AS above_ma60,
+ -- MA alignment (bullish = ma5 > ma10 > ma20 > ma60)
+ CASE WHEN l.ma5 > l.ma10 THEN 1 ELSE 0 END AS ma5_gt_ma10,
+ CASE WHEN l.ma10 > l.ma20 THEN 1 ELSE 0 END AS ma10_gt_ma20,
+ CASE WHEN l.ma20 > l.ma60 THEN 1 ELSE 0 END AS ma20_gt_ma60,
+ -- slope (5d change / 5)
+ (l.ma5 - p.ma5) / NULLIF(p.ma5, 0) AS ma5_slope,
+ (l.close - p.close) / NULLIF(p.close, 0) AS pct_chg,
+ -- volume ratio
+ l.volume / NULLIF(l.vol_ma20, 0) AS vol_ratio
+ FROM latest l
+ LEFT JOIN prev p ON l.ts_code = p.ts_code
+ """
+ result = conn.execute(sql).fetchdf()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return _empty_trend()
+
+ # ── Per-index stats ──
+ above_ma20_count = int(result["above_ma20"].sum())
+ above_ma60_count = int(result["above_ma60"].sum())
+ total = len(result)
+
+ # ── MA alignment score (bullish alignment) ──
+ alignment_cols = ["ma5_gt_ma10", "ma10_gt_ma20", "ma20_gt_ma60"]
+ avg_alignment = float(result[alignment_cols].mean().mean()) # fraction of alignments bullish
+
+ # ── Resonance: are indices moving in the same direction? ──
+ pct_chgs = result["pct_chg"].dropna()
+ if len(pct_chgs) >= 4:
+ positive = (pct_chgs > 0).sum()
+ negative = (pct_chgs < 0).sum()
+ resonance = max(positive, negative) / len(pct_chgs)
+ else:
+ resonance = 0.5
+
+ # ── Divergence: std of index returns (high = diverging) ──
+ divergence = float(pct_chgs.std()) if len(pct_chgs) > 1 else 0.0
+ # Normalize: 2% std → 1.0
+ divergence = min(divergence / 0.02, 1.0)
+
+ # ── Volume: what fraction of indices have above-avg volume? ──
+ vol_ratios = result["vol_ratio"].dropna()
+ vol_expanding = (vol_ratios > 1.0).sum() / len(vol_ratios) if len(vol_ratios) > 0 else 0.5
+
+ # ── Trend slope: average MA5 slope across indices ──
+ slopes = result["ma5_slope"].dropna()
+ avg_slope = float(slopes.mean()) if len(slopes) > 0 else 0.0
+ # Normalize: 2% slope → 1.0
+ slope_normalized = min(max(avg_slope / 0.02 + 0.5, 0.0), 1.0)
+
+ return {
+ "above_ma20_pct": round(above_ma20_count / total, 4),
+ "above_ma60_pct": round(above_ma60_count / total, 4),
+ "resonance": round(resonance, 4),
+ "divergence": round(divergence, 4),
+ "ma_alignment": round(avg_alignment, 4),
+ "vol_expanding_pct": round(vol_expanding, 4),
+ "avg_slope": round(avg_slope, 6),
+ "index_count": total,
+ "index_details": [
+ {
+ "ts_code": row["ts_code"],
+ "name": INDEX_CODES.get(row["ts_code"], row["ts_code"]),
+ "close": round(float(row["close"]), 2),
+ "pct_chg": round(float(row["pct_chg"] or 0) * 100, 2),
+ "above_ma20": bool(row["above_ma20"]),
+ "above_ma60": bool(row["above_ma60"]),
+ "vol_ratio": round(float(row["vol_ratio"] or 1.0), 2),
+ }
+ for _, row in result.iterrows()
+ ],
+ }
+
+
+def _empty_trend() -> dict:
+ return {
+ "above_ma20_pct": 0.5,
+ "above_ma60_pct": 0.5,
+ "resonance": 0.5,
+ "divergence": 0.5,
+ "ma_alignment": 0.5,
+ "vol_expanding_pct": 0.5,
+ "avg_slope": 0.0,
+ "index_count": 0,
+ "index_details": [],
+ }
+
+
+registry.register(FeatureDefinition(
+ name="trend_vector",
+ category="trend",
+ description="Index MA positioning, resonance/divergence, bullish alignment, volume expansion",
+ dependencies=[],
+ compute=_compute_trend_vector,
+))
diff --git a/src/ashare_dp/features/volume.py b/src/ashare_dp/features/volume.py
new file mode 100644
index 0000000..1555028
--- /dev/null
+++ b/src/ashare_dp/features/volume.py
@@ -0,0 +1,128 @@
+"""Volume features: total turnover, vs 5d/20d average, turnover tier."""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+from typing import Any
+
+import duckdb
+
+from ashare_dp.domain.features import FeatureDefinition
+from ashare_dp.features.registry import registry
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def _compute_volume_vector(db: Any, trade_date: date, _results: dict) -> dict[str, Any]:
+ """Compute volume profile: total turnover, comparison to 5d/20d averages.
+
+ Also reads the trading_calendar to get prior trading days.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ # Get the last 20 trading days from calendar
+ cal_sql = """
+ SELECT trade_date FROM trading_calendar
+ WHERE trade_date <= $trade_date AND is_trading_day = true
+ ORDER BY trade_date DESC
+ LIMIT 22
+ """
+ cal_dates = conn.execute(
+ cal_sql, {"trade_date": trade_date.isoformat()}
+ ).fetchall()
+
+ if not cal_dates:
+ return _empty_volume()
+
+ cal_dates = [row[0] for row in cal_dates]
+ today_str = cal_dates[0]
+
+ # Compute daily turnover for each of the last 20+ trading days
+ # Use a single DuckDB scan instead of repeated queries
+ date_list = "', '".join(str(d) for d in cal_dates)
+
+ sql = f"""
+ SELECT
+ trade_date,
+ SUM(amount) as daily_amount,
+ SUM(volume) as daily_volume,
+ COUNT(*) as stock_count
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date IN ('{date_list}')
+ GROUP BY trade_date
+ ORDER BY trade_date DESC
+ """
+ result = conn.execute(sql).fetchdf()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return _empty_volume()
+
+ today_row = result[result["trade_date"] == today_str]
+ if today_row.empty:
+ return _empty_volume()
+
+ today_amount = float(today_row["daily_amount"].iloc[0])
+ today_volume = float(today_row["daily_volume"].iloc[0])
+ today_count = int(today_row["stock_count"].iloc[0])
+
+ # 5d avg (today + prior 4 trading days)
+ recent_5 = result.head(5)
+ avg_5d_amount = float(recent_5["daily_amount"].mean())
+ avg_5d_volume = float(recent_5["daily_volume"].mean())
+
+ # 20d avg
+ recent_20 = result.head(min(20, len(result)))
+ avg_20d_amount = float(recent_20["daily_amount"].mean())
+ avg_20d_volume = float(recent_20["daily_volume"].mean())
+
+ vs_5d = round(today_amount / avg_5d_amount, 4) if avg_5d_amount > 0 else 1.0
+ vs_20d = round(today_amount / avg_20d_amount, 4) if avg_20d_amount > 0 else 1.0
+
+ # Turnover tier
+ amount_yi = today_amount / 1e8 # 亿
+ if amount_yi < 8000:
+ tier = "缩量"
+ elif amount_yi < 12000:
+ tier = "正常"
+ elif amount_yi < 15000:
+ tier = "放量"
+ else:
+ tier = "巨量"
+
+ return {
+ "total_turnover": today_amount,
+ "total_turnover_yi": round(amount_yi, 0),
+ "total_volume": int(today_volume),
+ "stock_count": today_count,
+ "vs_5d": vs_5d,
+ "vs_20d": vs_20d,
+ "avg_5d_amount": avg_5d_amount,
+ "avg_20d_amount": avg_20d_amount,
+ "tier": tier,
+ }
+
+
+def _empty_volume() -> dict:
+ return {
+ "total_turnover": 0,
+ "total_turnover_yi": 0,
+ "total_volume": 0,
+ "stock_count": 0,
+ "vs_5d": 1.0,
+ "vs_20d": 1.0,
+ "avg_5d_amount": 0,
+ "avg_20d_amount": 0,
+ "tier": "未知",
+ }
+
+
+registry.register(FeatureDefinition(
+ name="volume_vector",
+ category="volume",
+ description="Total market turnover, vs 5d/20d average comparison, turnover tier classification",
+ dependencies=[],
+ compute=_compute_volume_vector,
+))
diff --git a/src/ashare_dp/market/__init__.py b/src/ashare_dp/market/__init__.py
new file mode 100644
index 0000000..2fdc94b
--- /dev/null
+++ b/src/ashare_dp/market/__init__.py
@@ -0,0 +1,15 @@
+"""MARKET INTELLIGENCE — market state inference and interpretation.
+
+Knows nothing about signals or execution.
+"""
+from ashare_dp.market.state import infer_market_state
+from ashare_dp.market.leadership import assess_leaders
+from ashare_dp.market.opportunity import rank_opportunities
+from ashare_dp.market.flow import compute_flow
+from ashare_dp.market.sentiment import assess_sentiment
+from ashare_dp.market.memory import StateStore, state_store
+
+__all__ = [
+ "infer_market_state", "assess_leaders", "rank_opportunities",
+ "compute_flow", "assess_sentiment", "StateStore", "state_store",
+]
diff --git a/src/ashare_dp/market/flow.py b/src/ashare_dp/market/flow.py
new file mode 100644
index 0000000..7880dff
--- /dev/null
+++ b/src/ashare_dp/market/flow.py
@@ -0,0 +1,119 @@
+"""Flow Engine — money flow graph from turnover changes.
+
+Phase 1: uses turnover change as proxy for capital flow.
+Phase 2: integrates 北向/ETF/主力/融资/龙虎榜 data.
+"""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import duckdb
+from loguru import logger
+
+from ashare_dp.domain.context import FlowEdge, FlowGraph
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def compute_flow(trade_date: date) -> FlowGraph:
+ """Compute money flow graph from industry turnover changes.
+
+ Identifies industries with rising vs falling turnover,
+ creates directed edges from losers to gainers.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ end_str = trade_date.strftime("%Y-%m-%d")
+ start_str = (trade_date - timedelta(days=20)).strftime("%Y-%m-%d")
+
+ sql = f"""
+ WITH normalized AS (
+ SELECT
+ k.ts_code AS ts_code,
+ k.trade_date, k.amount
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true) k
+ WHERE k.trade_date >= $start_date AND k.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_amount
+ FROM ranked WHERE rn <= 5 GROUP BY industry_name
+ ),
+ prior AS (
+ SELECT industry_name, SUM(total_amount) AS prior_amount
+ FROM ranked WHERE rn > 5 AND rn <= 10 GROUP BY industry_name
+ )
+ SELECT
+ COALESCE(r.industry_name, p.industry_name) AS industry_name,
+ COALESCE(r.recent_amount, 0) AS recent_amount,
+ COALESCE(p.prior_amount, 0) AS prior_amount,
+ CASE WHEN COALESCE(p.prior_amount, 0) > 0
+ THEN (r.recent_amount - p.prior_amount) / p.prior_amount
+ ELSE 0 END AS change
+ FROM recent r
+ FULL OUTER JOIN prior p ON r.industry_name = p.industry_name
+ WHERE COALESCE(r.recent_amount, 0) + COALESCE(p.prior_amount, 0) > 0
+ """
+ result = conn.execute(
+ sql.replace("$start_date", f"'{start_str}'").replace("$end_date", f"'{end_str}'")
+ ).fetchdf()
+ except Exception as e:
+ logger.warning(f"Flow computation failed: {e}")
+ conn.close()
+ return FlowGraph()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return FlowGraph()
+
+ # Build flow edges from losers to gainers
+ changes = {}
+ for _, row in result.iterrows():
+ changes[row["industry_name"]] = float(row["change"])
+
+ gainers = sorted([(k, v) for k, v in changes.items() if v > 0.05], key=lambda x: -x[1])
+ losers = sorted([(k, v) for k, v in changes.items() if v < -0.05], key=lambda x: x[1])
+
+ edges = []
+ for i in range(min(len(gainers), len(losers), 5)):
+ los_name, los_chg = losers[i]
+ win_name, win_chg = gainers[i]
+
+ # Magnitude string
+ los_mag = "↓↓" if los_chg < -0.15 else "↓"
+ win_mag = "↑↑" if win_chg > 0.15 else "↑"
+ magnitude = f"{los_mag}→{win_mag}"
+
+ strength = min((win_chg - los_chg) / 2, 1.0)
+
+ edges.append(FlowEdge(
+ source=los_name,
+ target=win_name,
+ magnitude=magnitude,
+ strength=round(strength, 4),
+ ))
+
+ net_inflow = {k: round(v, 4) for k, v in changes.items() if v > 0}
+ net_outflow = {k: round(v, 4) for k, v in changes.items() if v < 0}
+
+ return FlowGraph(
+ flows=sorted(edges, key=lambda e: -e.strength),
+ net_inflow=net_inflow,
+ net_outflow=net_outflow,
+ )
diff --git a/src/ashare_dp/market/knowledge.py b/src/ashare_dp/market/knowledge.py
new file mode 100644
index 0000000..8c75aa2
--- /dev/null
+++ b/src/ashare_dp/market/knowledge.py
@@ -0,0 +1,166 @@
+"""Knowledge Graph Engine — Theme activation, concept flow, narrative integration.
+
+Maps daily market data (industry turnover, leader states, opportunities)
+to the curated theme knowledge graph. Computes theme relevance scores
+and detects theme-level rotation.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+
+from loguru import logger
+
+from ashare_dp.domain.knowledge import (
+ Theme, Concept, ThemeEdge, ThemeGraph, THEME_KNOWLEDGE,
+)
+
+
+def activate_themes(
+ trade_date: date,
+ leaders: dict | None = None,
+ opportunities: list | None = None,
+) -> ThemeGraph:
+ """Compute theme relevance from today's market data.
+
+ For each theme in the knowledge graph:
+ - Match concepts to industries by keyword search on stock names
+ - Compute relevance from: leader health, opportunity score, turnover
+ - Build theme-level rotation edges from flow data
+
+ Returns a ThemeGraph with scored themes and edges.
+ """
+ themes = []
+ leaders = leaders or {}
+ opps = opportunities or []
+
+ for theme_tmpl in THEME_KNOWLEDGE:
+ theme = Theme(name=theme_tmpl.name, concepts=[])
+ total_score = 0.0
+ concept_count = 0
+
+ for conc_tmpl in theme_tmpl.concepts:
+ concept = Concept(
+ name=conc_tmpl.name,
+ keywords=conc_tmpl.keywords,
+ leaders=conc_tmpl.leaders,
+ )
+
+ # Find matching opportunities for this concept
+ concept_score = 0.0
+
+ for opp in (opps or []):
+ # Check if opportunity name matches concept keywords
+ opp_name = opp.name if hasattr(opp, 'name') else str(opp)
+ for kw in conc_tmpl.keywords:
+ if kw in opp_name:
+ concept_score = max(concept_score, opp.score if hasattr(opp, 'score') else 50)
+ break
+
+ # Check leader health by matching concept keywords against industry names
+ for ind, ld_data in leaders.items():
+ if not isinstance(ld_data, dict):
+ continue
+ state = ld_data.get("state", "unknown")
+ # Match concept keywords against industry name
+ for kw in conc_tmpl.keywords:
+ if kw in ind or kw in str(ld_data.get("leader", "")):
+ if state in ("leading", "growing"):
+ concept_score = max(concept_score, 80)
+ elif state in ("birth", "recovering"):
+ concept_score = max(concept_score, 60)
+ elif state in ("exhausted",):
+ concept_score = max(concept_score, 40)
+ elif state in ("breaking", "dead"):
+ concept_score = max(concept_score, 10)
+ break
+
+ # Match concept keywords against opportunity names
+ for opp in (opps or []):
+ opp_name = opp.name if hasattr(opp, 'name') else str(opp)
+ top_stocks = getattr(opp, 'top_stocks', []) or []
+ for kw in conc_tmpl.keywords:
+ if kw in opp_name:
+ concept_score = max(concept_score, opp.score if hasattr(opp, 'score') else 50)
+ break
+ for s in top_stocks:
+ if kw in str(s):
+ concept_score = max(concept_score, opp.score if hasattr(opp, 'score') else 50)
+ break
+
+ concept.relevance_score = round(concept_score, 1)
+ if concept_score > 0:
+ theme.concepts.append(concept)
+ total_score += concept_score
+ concept_count += 1
+
+ if concept_count > 0:
+ theme.relevance_score = round(total_score / concept_count, 1)
+ else:
+ theme.relevance_score = 0.0
+
+ # Momentum label
+ if theme.relevance_score >= 70:
+ theme.momentum = "↑↑"
+ elif theme.relevance_score >= 50:
+ theme.momentum = "↑"
+ elif theme.relevance_score >= 30:
+ theme.momentum = "→"
+ elif theme.relevance_score > 0:
+ theme.momentum = "↓"
+ else:
+ theme.momentum = ""
+
+ themes.append(theme)
+
+ # Sort by relevance
+ themes.sort(key=lambda t: t.relevance_score, reverse=True)
+
+ # Build theme edges from top-to-bottom flow
+ edges = []
+ active = [t for t in themes if t.relevance_score > 30]
+ if len(active) >= 2:
+ for i in range(min(len(active) - 1, 3)):
+ edges.append(ThemeEdge(
+ source=active[-1 - i].name,
+ target=active[i].name,
+ strength=round(active[i].relevance_score / 100, 4),
+ ))
+
+ return ThemeGraph(themes=themes, edges=edges)
+
+
+def describe_theme_graph(tg: ThemeGraph) -> str:
+ """Generate a human-readable description of the theme landscape."""
+ active = [t for t in tg.themes if t.relevance_score > 0]
+ if not active:
+ return "无明显主题热点"
+
+ parts = []
+ top3 = active[:3]
+ parts.append(f"主题集中在{'、'.join(t.name for t in top3)}")
+
+ # Describe top theme's concepts
+ if top3 and top3[0].concepts:
+ concepts = [c.name for c in top3[0].concepts if c.relevance_score > 0]
+ if concepts:
+ parts.append(f"{top3[0].name}主题下{'、'.join(concepts[:3])}最活跃")
+
+ # Describe rotation
+ if tg.edges:
+ e = tg.edges[0]
+ parts.append(f"资金从{e.source}流向{e.target}")
+
+ return "。".join(parts) + "。"
+
+
+def get_theme_for_stock(stock_name: str, ts_code: str = "") -> list[str]:
+ """Find which themes a stock belongs to."""
+ matched = []
+ for theme in THEME_KNOWLEDGE:
+ for concept in theme.concepts:
+ for kw in concept.keywords:
+ if kw in stock_name or kw in ts_code:
+ if theme.name not in matched:
+ matched.append(theme.name)
+ return matched
diff --git a/src/ashare_dp/market/leadership.py b/src/ashare_dp/market/leadership.py
new file mode 100644
index 0000000..2c6f8df
--- /dev/null
+++ b/src/ashare_dp/market/leadership.py
@@ -0,0 +1,137 @@
+"""Leadership Engine — assess leader lifecycle for each major industry.
+
+Not just Alive/Dead. Full lifecycle: Birth → Growing → Leading →
+Exhausted → Breaking → Dead → Recovering.
+
+Uses daily K-line data to determine leader state from price/MA/volume.
+"""
+
+from __future__ import annotations
+
+from datetime import date
+
+import duckdb
+from loguru import logger
+
+from ashare_dp.domain.leadership import LeaderState
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def assess_leaders(trade_date: date) -> dict[str, dict]:
+ """For each major industry, identify the leader stock and its lifecycle state.
+
+ Returns:
+ {industry_name: {leader, state, close, above_ma20, above_ma60,
+ momentum, volume_ratio, days_above_ma20}}
+
+ Phase 1: uses top stock by amount per industry from stock_industry join.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT
+ k.ts_code AS ts_code,
+ k.trade_date, k.trade_time, k.close, k.volume, k.amount, k.high, k.low
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true) k
+ WHERE k.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,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time DESC) as rn
+ FROM normalized
+ ),
+ latest AS (
+ SELECT * FROM with_ma WHERE rn = 1
+ ),
+ prev AS (
+ SELECT * FROM with_ma WHERE rn = 21
+ ),
+ ranked AS (
+ SELECT
+ l.ts_code, l.close, l.volume, l.amount,
+ l.ma20, l.ma60, l.vol_ma20,
+ p.close AS close_20d_ago,
+ (l.close - p.close) / NULLIF(p.close, 0) AS momentum_20d,
+ l.volume / NULLIF(l.vol_ma20, 0) AS vol_ratio,
+ si.industry_name,
+ ROW_NUMBER() OVER (PARTITION BY si.industry_name ORDER BY l.amount DESC) as amt_rank
+ FROM latest l
+ JOIN stock_industry si ON l.ts_code = si.ts_code
+ LEFT JOIN prev p ON l.ts_code = p.ts_code
+ WHERE l.amount > 1e8
+ )
+ SELECT * FROM ranked WHERE amt_rank = 1
+ ORDER BY industry_name
+ """
+ result = conn.execute(sql, {"trade_date": trade_date.isoformat()}).fetchdf()
+ except Exception as e:
+ logger.warning(f"Leadership assessment failed: {e}")
+ conn.close()
+ return {}
+ finally:
+ conn.close()
+
+ leaders = {}
+ for _, row in result.iterrows():
+ industry = row["industry_name"]
+ close = float(row["close"])
+ ma20 = float(row["ma20"] or close)
+ ma60 = float(row["ma60"] or close)
+ vol_ratio = float(row["vol_ratio"] or 1.0)
+ import math as _math
+ momentum_raw = row["momentum_20d"]
+ momentum = float(momentum_raw) if (momentum_raw is not None and not (isinstance(momentum_raw, float) and _math.isnan(momentum_raw))) else 0.0
+
+ above_ma20 = close > ma20
+ above_ma60 = close > ma60
+
+ state = _classify_leader_state(
+ above_ma20=above_ma20,
+ above_ma60=above_ma60,
+ vol_ratio=vol_ratio,
+ momentum=momentum,
+ )
+
+ leaders[industry] = {
+ "leader": row["ts_code"],
+ "state": state.value,
+ "close": round(close, 2),
+ "above_ma20": above_ma20,
+ "above_ma60": above_ma60,
+ "momentum_20d": round(momentum, 4),
+ "volume_ratio": round(vol_ratio, 2),
+ }
+
+ return leaders
+
+
+def _classify_leader_state(
+ above_ma20: bool,
+ above_ma60: bool,
+ vol_ratio: float,
+ momentum: float,
+) -> LeaderState:
+ """Classify leader lifecycle from technical indicators."""
+ if not above_ma60:
+ return LeaderState.DEAD
+ if not above_ma20:
+ # Was it above MA20 recently? If so, breaking. If not, dead.
+ return LeaderState.BREAKING
+
+ # Above both MAs
+ if momentum > 0.05 and vol_ratio > 1.2:
+ return LeaderState.LEADING
+ elif momentum > 0.02:
+ return LeaderState.GROWING
+ elif momentum > 0:
+ return LeaderState.BIRTH if vol_ratio > 0.8 else LeaderState.EXHAUSTED
+ elif momentum > -0.02:
+ return LeaderState.EXHAUSTED
+ else:
+ return LeaderState.BREAKING # losing momentum fast
diff --git a/src/ashare_dp/market/memory.py b/src/ashare_dp/market/memory.py
new file mode 100644
index 0000000..8503129
--- /dev/null
+++ b/src/ashare_dp/market/memory.py
@@ -0,0 +1,154 @@
+"""Time Series State Store — persists MarketState snapshots for historical lookup.
+
+NOT limited to daily. Schema: (entity, timeframe, timestamp, state).
+Supports find_similar() for historical pattern matching (future Signal Engine).
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+from typing import Any
+
+from loguru import logger
+
+from ashare_dp.domain.state import MarketState
+
+
+class StateStore:
+ """Persist and query MarketState snapshots.
+
+ Uses the Database class API: db.execute() for writes, db.query() for reads.
+ """
+
+ def save(
+ self,
+ db: Any,
+ entity: str,
+ timeframe: str,
+ timestamp: datetime,
+ state: MarketState,
+ ) -> None:
+ """Save a MarketState snapshot."""
+ try:
+ state_json = json.dumps(state.to_dict(), ensure_ascii=False)
+ db.execute(
+ "INSERT OR REPLACE INTO state_snapshot (entity, timeframe, timestamp, state) "
+ "VALUES (?, ?, ?, ?)",
+ (entity, timeframe, timestamp, state_json),
+ )
+ except Exception as e:
+ logger.warning(f"StateStore.save failed: {e}")
+
+ def get_latest(
+ self, db: Any, entity: str, timeframe: str
+ ) -> MarketState | None:
+ """Get the most recent MarketState for an entity/timeframe."""
+ try:
+ rows = db.query(
+ "SELECT state, timestamp FROM state_snapshot "
+ "WHERE entity = ? AND timeframe = ? "
+ "ORDER BY timestamp DESC LIMIT 1",
+ (entity, timeframe),
+ )
+ if rows:
+ state_dict = json.loads(rows[0][0])
+ return _dict_to_state(state_dict)
+ return None
+ except Exception as e:
+ logger.warning(f"StateStore.get_latest failed: {e}")
+ return None
+
+ def query(
+ self,
+ db: Any,
+ entity: str,
+ timeframe: str,
+ start: datetime,
+ end: datetime,
+ ) -> list[MarketState]:
+ """Query MarketState snapshots in a time range."""
+ try:
+ rows = db.query(
+ "SELECT state, timestamp FROM state_snapshot "
+ "WHERE entity = ? AND timeframe = ? "
+ "AND timestamp >= ? AND timestamp <= ? "
+ "ORDER BY timestamp ASC",
+ (entity, timeframe, start, end),
+ )
+ return [_dict_to_state(json.loads(row[0])) for row in rows]
+ except Exception as e:
+ logger.warning(f"StateStore.query failed: {e}")
+ return []
+
+ def find_similar(
+ self, db: Any, state: MarketState, top_k: int = 10
+ ) -> list[tuple[MarketState, float]]:
+ """Find historically similar MarketStates by cosine similarity."""
+ try:
+ rows = db.query(
+ "SELECT state, timestamp FROM state_snapshot "
+ "WHERE entity = 'market' AND timeframe = '1d' "
+ "ORDER BY timestamp DESC LIMIT 1000"
+ )
+ target = state.to_vector()
+ scored = []
+ for row in rows:
+ hist_dict = json.loads(row[0])
+ hist = _dict_to_state(hist_dict)
+ hist_vec = hist.to_vector()
+ sim = _cosine_similarity(target, hist_vec)
+ scored.append((hist, sim))
+ scored.sort(key=lambda x: x[1], reverse=True)
+ return scored[:top_k]
+ except Exception as e:
+ logger.warning(f"StateStore.find_similar failed: {e}")
+ return []
+
+ def count(self, db: Any, entity: str, timeframe: str) -> int:
+ """Count stored snapshots."""
+ try:
+ rows = db.query(
+ "SELECT COUNT(*) FROM state_snapshot WHERE entity = ? AND timeframe = ?",
+ (entity, timeframe),
+ )
+ return int(rows[0][0]) if rows else 0
+ except Exception:
+ return 0
+
+
+def _dict_to_state(d: dict) -> MarketState:
+ """Reconstruct MarketState from serialized dict."""
+ dims = d.get("dimensions", {})
+ return MarketState(
+ timestamp=datetime.fromisoformat(d["timestamp"]),
+ trade_date=datetime.fromisoformat(d["trade_date"]).date()
+ if "trade_date" in d
+ else datetime.fromisoformat(d["timestamp"]).date(),
+ version=d.get("version", "1.0"),
+ source=d.get("source", "ashare_dp"),
+ trend=dims.get("trend", 0.0),
+ fear=dims.get("fear", 0.0),
+ liquidity=dims.get("liquidity", 0.0),
+ rotation=dims.get("rotation", 0.0),
+ participation=dims.get("participation", 0.0),
+ volatility=dims.get("volatility", 0.0),
+ breadth=dims.get("breadth", 0.0),
+ confidence=d.get("confidence", 0.0),
+ quality=d.get("quality", 0.0),
+ )
+
+
+def _cosine_similarity(a: list[float], b: list[float]) -> float:
+ """Compute cosine similarity between two vectors."""
+ import math
+ dot = sum(x * y for x, y in zip(a, b))
+ na = math.sqrt(sum(x * x for x in a))
+ nb = math.sqrt(sum(x * x for x in b))
+ if na == 0 or nb == 0:
+ return 0.0
+ return dot / (na * nb)
+
+
+# Module-level singleton
+state_store = StateStore()
diff --git a/src/ashare_dp/market/opportunity.py b/src/ashare_dp/market/opportunity.py
new file mode 100644
index 0000000..acf35e8
--- /dev/null
+++ b/src/ashare_dp/market/opportunity.py
@@ -0,0 +1,198 @@
+"""Opportunity Engine — ranks tradable opportunities by expected participation.
+
+Not industry returns. Not leader rankings. Forward-looking scores for
+"where can I make money in the next 1-3 days?"
+
+Scoring: turnover trend + persistence + breadth within industry + leader state.
+"""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import duckdb
+from loguru import logger
+
+from ashare_dp.domain.context import Opportunity
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+
+
+def rank_opportunities(trade_date: date, top_n: int = 10) -> list[Opportunity]:
+ """Score industries by multi-factor opportunity model.
+
+ Factors:
+ - Turnover momentum (5d change vs prior 5d)
+ - Persistence (consecutive days as turnover leader)
+ - Internal breadth (% of stocks in industry advancing)
+ - Leader health (from Leadership Engine)
+
+ Returns sorted list of Opportunity objects.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ try:
+ # Compute per-industry turnover trends + internal breadth
+ sql = f"""
+ WITH normalized AS (
+ SELECT
+ k.ts_code AS ts_code,
+ k.trade_date, k.close, k.open, k.amount
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true) k
+ WHERE k.trade_date >= $start_date AND k.trade_date <= $end_date
+ ),
+ daily AS (
+ SELECT
+ n.ts_code, n.trade_date, n.close, n.open, 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,
+ COUNT(*) AS stock_count,
+ COUNT(*) FILTER (WHERE close > open) AS advance_count
+ 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_amount,
+ SUM(stock_count) AS recent_stocks,
+ SUM(advance_count) * 1.0 / NULLIF(SUM(stock_count), 0) AS advance_ratio
+ FROM ranked WHERE rn <= 5
+ GROUP BY industry_name
+ ),
+ prior AS (
+ SELECT industry_name,
+ SUM(total_amount) AS prior_amount
+ FROM ranked WHERE rn > 5 AND rn <= 10
+ GROUP BY industry_name
+ ),
+ persistence AS (
+ SELECT
+ industry_name,
+ COUNT(*) FILTER (WHERE daily_rank <= 3) AS lead_days
+ FROM (
+ SELECT *,
+ ROW_NUMBER() OVER (PARTITION BY trade_date ORDER BY total_amount DESC) as daily_rank
+ FROM industry_daily
+ WHERE trade_date >= $recent_10
+ ) sub
+ WHERE daily_rank <= 3
+ GROUP BY industry_name
+ )
+ SELECT
+ COALESCE(r.industry_name, p.industry_name) AS industry_name,
+ COALESCE(r.recent_amount, 0) AS recent_amount,
+ COALESCE(p.prior_amount, 0) AS prior_amount,
+ CASE WHEN COALESCE(p.prior_amount, 0) > 0
+ THEN (r.recent_amount - p.prior_amount) / p.prior_amount
+ ELSE 0 END AS turnover_change,
+ COALESCE(r.recent_stocks, 0) AS stock_count,
+ COALESCE(r.advance_ratio, 0.5) AS advance_ratio,
+ COALESCE(ps.lead_days, 0) AS persistence_days
+ FROM recent r
+ FULL OUTER JOIN prior p ON r.industry_name = p.industry_name
+ LEFT JOIN persistence ps ON r.industry_name = ps.industry_name
+ WHERE COALESCE(r.recent_amount, 0) + COALESCE(p.prior_amount, 0) > 0
+ """
+ end_str = trade_date.strftime("%Y-%m-%d")
+ start_str = (trade_date - timedelta(days=30)).strftime("%Y-%m-%d")
+ recent_10 = (trade_date - timedelta(days=14)).strftime("%Y-%m-%d")
+
+ result = conn.execute(
+ sql.replace("$start_date", f"'{start_str}'")
+ .replace("$end_date", f"'{end_str}'")
+ .replace("$recent_10", f"'{recent_10}'")
+ ).fetchdf()
+ except Exception as e:
+ logger.warning(f"Opportunity ranking failed: {e}")
+ conn.close()
+ return []
+ finally:
+ conn.close()
+
+ if result.empty:
+ return []
+
+ # Score each industry
+ opportunities = []
+ for _, row in result.iterrows():
+ chg = float(row["turnover_change"])
+ persistence = int(row["persistence_days"])
+ advance_ratio = float(row["advance_ratio"])
+ stock_count = int(row["stock_count"])
+
+ # Skip tiny industries
+ if stock_count < 3:
+ continue
+
+ # Composite score (0-100)
+ momentum_score = min(max(chg * 100 + 50, 0), 100) # normalize to 0-100
+ persistence_score = min(persistence * 20, 40) # up to 40 points
+ breadth_score = advance_ratio * 30 # up to 30 points
+
+ score = momentum_score * 0.5 + persistence_score + breadth_score
+
+ # Lifecycle classification
+ if persistence >= 3 and chg > 0.1:
+ lifecycle = "Accelerating"
+ elif persistence >= 1 and chg > 0.05:
+ lifecycle = "Early"
+ elif chg > 0:
+ lifecycle = "Peak"
+ else:
+ lifecycle = "Declining"
+
+ opportunities.append(Opportunity(
+ name=row["industry_name"],
+ score=round(min(score, 100), 1),
+ lifecycle=lifecycle,
+ persistence_days=persistence,
+ catalyst="资金轮动" if chg > 0.1 else "",
+ top_stocks=[], # filled by router
+ ))
+
+ # Sort by score desc
+ opportunities.sort(key=lambda o: o.score, reverse=True)
+
+ # Get top stocks for top opportunities (fresh connection needed)
+ if opportunities:
+ import duckdb
+ fresh_conn = duckdb.connect("data/duckdb/ashare.db")
+ try:
+ _fill_top_stocks(fresh_conn, trade_date, opportunities[:top_n])
+ finally:
+ fresh_conn.close()
+
+ return opportunities[:top_n]
+
+
+def _fill_top_stocks(conn, trade_date: date, opportunities: list[Opportunity]):
+ """For each opportunity, find the top 3 stocks by amount."""
+ parquet_glob = kline_glob()
+
+ for opp in opportunities:
+ try:
+ sql = f"""
+ SELECT
+ k.ts_code AS ts_code
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true) k
+ JOIN stock_industry si ON k.ts_code = si.ts_code
+ WHERE k.trade_date = ? AND si.industry_name = ?
+ ORDER BY k.amount DESC
+ LIMIT 3
+ """
+ rows = conn.execute(sql, [trade_date.isoformat(), opp.name]).fetchall()
+ opp.top_stocks = [r[0] for r in rows]
+ except Exception:
+ pass
diff --git a/src/ashare_dp/market/recommendations.py b/src/ashare_dp/market/recommendations.py
new file mode 100644
index 0000000..d2d7f0f
--- /dev/null
+++ b/src/ashare_dp/market/recommendations.py
@@ -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 "观望 — 方向不明,暂不参与"
diff --git a/src/ashare_dp/market/sentiment.py b/src/ashare_dp/market/sentiment.py
new file mode 100644
index 0000000..9396526
--- /dev/null
+++ b/src/ashare_dp/market/sentiment.py
@@ -0,0 +1,195 @@
+"""Sentiment Engine — real limit-up/down data from akshare.
+
+Phase 2: fetches 涨停/跌停/炸板 data via EM API,
+stores daily snapshots, computes profit effect score.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+
+from loguru import logger
+
+
+def fetch_daily_sentiment(trade_date: date | None = None) -> dict:
+ """Fetch today's sentiment data from akshare.
+
+ Uses East Money APIs (accessible outside China — different from stock data).
+ Returns structured sentiment dict for API/dashboard consumption.
+ """
+ import akshare as ak
+
+ if trade_date is None:
+ trade_date = date.today()
+
+ date_str = trade_date.strftime("%Y%m%d")
+
+ # ── Fetch limit-up pool ──
+ try:
+ zt_df = ak.stock_zt_pool_em(date=date_str)
+ limit_up_count = len(zt_df)
+ # Extract board stats
+ board_stats = _extract_board_stats(zt_df)
+ except Exception as e:
+ logger.warning(f"Limit-up fetch failed: {e}")
+ limit_up_count = 0
+ board_stats = {}
+
+ # ── Fetch limit-down pool ──
+ try:
+ dt_df = ak.stock_zt_pool_dtgc_em(date=date_str)
+ limit_down_count = len(dt_df)
+ except Exception as e:
+ logger.warning(f"Limit-down fetch failed: {e}")
+ limit_down_count = 0
+
+ # ── Fetch broken-board pool (炸板) ──
+ try:
+ zb_df = ak.stock_zt_pool_zbgc_em(date=date_str)
+ broken_board_count = len(zb_df)
+ except Exception as e:
+ logger.warning(f"Broken-board fetch failed: {e}")
+ broken_board_count = 0
+
+ # ── Compute derived metrics ──
+ board_total = limit_up_count + broken_board_count
+ broken_board_rate = round(broken_board_count / board_total, 4) if board_total > 0 else 0
+
+ # Board progression rate: 连板数>=2 / total limit-up
+ consecutive_count = board_stats.get("consecutive_count", 0)
+ consecutive_board_rate = round(consecutive_count / limit_up_count, 4) if limit_up_count > 0 else 0
+
+ # First-board count
+ first_board_count = board_stats.get("first_board_count", 0)
+
+ # Profit effect score (0-100)
+ profit_score = _compute_profit_score(
+ limit_up_count=limit_up_count,
+ limit_down_count=limit_down_count,
+ broken_board_rate=broken_board_rate,
+ consecutive_board_rate=consecutive_board_rate,
+ total_stocks=board_stats.get("total_analyzed", 5000),
+ )
+
+ # Profit effect label
+ if profit_score >= 80:
+ profit_effect = "Strong"
+ elif profit_score >= 60:
+ profit_effect = "Average"
+ elif profit_score >= 40:
+ profit_effect = "Weak"
+ else:
+ profit_effect = "Collapse"
+
+ return {
+ "status": "live",
+ "trade_date": trade_date.isoformat(),
+ "profit_effect": profit_effect,
+ "profit_effect_score": profit_score,
+ "limit_up_count": limit_up_count,
+ "limit_down_count": limit_down_count,
+ "broken_board_count": broken_board_count,
+ "broken_board_rate": broken_board_rate,
+ "first_board_count": first_board_count,
+ "consecutive_count": consecutive_count,
+ "consecutive_board_rate": consecutive_board_rate,
+ "max_consecutive": board_stats.get("max_consecutive", 0),
+ "top_industries": board_stats.get("top_industries", []),
+ }
+
+
+def _extract_board_stats(zt_df) -> dict:
+ """Extract board statistics from limit-up DataFrame."""
+ total = len(zt_df)
+ if total == 0:
+ return {}
+
+ consecutive_count = 0
+ first_board_count = 0
+ max_consecutive = 0
+ industry_counts = {}
+
+ for _, row in zt_df.iterrows():
+ # Parse 连板数
+ lb = row.get("连板数", 0)
+ try:
+ lb = int(lb)
+ except (ValueError, TypeError):
+ lb = 1
+
+ if lb >= 2:
+ consecutive_count += 1
+ if lb == 1:
+ first_board_count += 1
+ max_consecutive = max(max_consecutive, lb)
+
+ # Industry
+ ind = row.get("所属行业", "其他")
+ industry_counts[ind] = industry_counts.get(ind, 0) + 1
+
+ top_industries = sorted(industry_counts.items(), key=lambda x: -x[1])[:5]
+
+ return {
+ "consecutive_count": consecutive_count,
+ "first_board_count": first_board_count,
+ "max_consecutive": max_consecutive,
+ "top_industries": [{"name": n, "count": c} for n, c in top_industries],
+ "total_analyzed": total,
+ }
+
+
+def _compute_profit_score(
+ limit_up_count: int,
+ limit_down_count: int,
+ broken_board_rate: float,
+ consecutive_board_rate: float,
+ total_stocks: int = 5000,
+) -> float:
+ """Compute profit effect score (0-100).
+
+ Factors:
+ - Limit-up ratio vs total stocks (more = better, up to 50 points)
+ - Limit-up/down ratio (more ups than downs = better, up to 25 points)
+ - Broken board rate penalty (higher = worse, up to -15 points)
+ - Consecutive board rate (higher = stronger sentiment, up to 10 points)
+ """
+ score = 50.0
+
+ # Limit-up breadth
+ zt_pct = limit_up_count / max(total_stocks, 1) * 100
+ score += min(zt_pct * 3, 20) # 0-20 bonus
+
+ # Up/down ratio
+ if limit_down_count > 0:
+ ratio = limit_up_count / limit_down_count
+ score += min(ratio * 2, 15) # 0-15 bonus
+ else:
+ score += 15
+
+ # Broken board penalty
+ score -= broken_board_rate * 20 # 0 to -20
+
+ # Consecutive board bonus
+ score += consecutive_board_rate * 15 # 0 to 15
+
+ return round(max(0, min(100, score)), 1)
+
+
+def assess_sentiment(trade_date: date | None = None) -> dict:
+ """Main entry point — fetch sentiment for a trading date.
+
+ Tries akshare first. Falls back to stored data if API unavailable.
+ """
+ try:
+ return fetch_daily_sentiment(trade_date)
+ except Exception as e:
+ logger.warning(f"Sentiment assessment failed, using fallback: {e}")
+ return {
+ "status": "error",
+ "profit_effect": None,
+ "limit_up_count": None,
+ "limit_down_count": None,
+ "broken_board_rate": None,
+ "consecutive_board_rate": None,
+ "profit_effect_score": None,
+ }
diff --git a/src/ashare_dp/market/state.py b/src/ashare_dp/market/state.py
new file mode 100644
index 0000000..9b63d06
--- /dev/null
+++ b/src/ashare_dp/market/state.py
@@ -0,0 +1,89 @@
+"""Market State Engine — infers MarketState from feature vectors.
+
+Takes computed features from the Feature Registry, produces a
+continuous MarketState vector. Does NOT import individual features.
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime
+from typing import Any
+
+from loguru import logger
+
+from ashare_dp.domain.state import MarketState
+from ashare_dp.features.registry import FeatureRegistry
+
+
+def infer_market_state(
+ features: dict[str, dict[str, Any]],
+ trade_date: date,
+ timestamp: datetime | None = None,
+) -> MarketState:
+ """Infer MarketState from feature vectors.
+
+ The Feature Registry computes raw features. This engine
+ transforms them into the unified MarketState domain object.
+
+ Args:
+ features: Dict mapping feature name → feature value dict,
+ as returned by FeatureRegistry.compute_all().
+ trade_date: The trading date these features describe.
+ timestamp: Optional override for the state timestamp.
+
+ Returns:
+ MarketState domain object with all dimensions populated.
+ """
+ if timestamp is None:
+ timestamp = datetime.now()
+
+ try:
+ state = MarketState.from_features(features, trade_date, timestamp)
+ except Exception as e:
+ logger.error(f"MarketState inference failed: {e}")
+ state = MarketState(
+ timestamp=timestamp,
+ trade_date=trade_date,
+ confidence=0.0,
+ quality=0.0,
+ )
+
+ return state
+
+
+def state_to_label(state: MarketState) -> str:
+ """Derive a human-readable regime label from the state vector.
+
+ This is for DISPLAY ONLY. Downstream engines use the vector
+ directly, never the label.
+
+ Classification logic:
+ - trend > 0.65, fear < 0.35 → "趋势"
+ - trend > 0.65, fear >= 0.35 → "波动上升"
+ - trend 0.35–0.65 → "震荡"
+ - trend < 0.35, fear > 0.65 → "恐慌"
+ - trend low, fear low → "低迷"
+ - trend improving, fear decreasing → "复苏"
+ """
+ t = state.trend
+ f = state.fear
+ l = state.liquidity
+
+ if t > 0.65 and f < 0.35:
+ if l > 0.7:
+ return "趋势"
+ return "趋势(缩量)"
+ elif t > 0.65 and f >= 0.35:
+ return "波动上升"
+ elif t > 0.45:
+ if f < 0.4:
+ return "震荡偏强"
+ return "震荡"
+ elif t > 0.25:
+ return "震荡偏弱"
+ elif f > 0.65:
+ return "恐慌"
+ elif t < 0.25 and f < 0.35:
+ return "低迷"
+ else:
+ return "复苏"
diff --git a/src/ashare_dp/scheduler/jobs.py b/src/ashare_dp/scheduler/jobs.py
deleted file mode 100644
index a74bbb8..0000000
--- a/src/ashare_dp/scheduler/jobs.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""Scheduler job implementations."""
-
-from __future__ import annotations
-
-from datetime import date, datetime
-
-from loguru import logger
-
-from ashare_dp.core.calendar import BEIJING_TZ
-from ashare_dp.data.eod import EODPipeline
-from ashare_dp.storage.repository import KLineRepository
-
-
-async def eod_pull_job():
- """End-of-day data pull job.
-
- Triggered at 15:05 Beijing time on trading days.
- Pulls daily, minute, weekly, and monthly data for all active stocks.
- """
- now = datetime.now(BEIJING_TZ)
- today = now.date()
-
- # Verify today is a weekday (trading day check done by scheduler)
- if now.weekday() >= 5:
- logger.info("EOD: Weekend, skipping")
- return
-
- logger.info(f"EOD job starting for {today.isoformat()}...")
-
- pipeline = EODPipeline()
-
- try:
- symbols = pipeline.get_active_symbols()
- logger.info(f"EOD: {len(symbols)} active stocks")
- except Exception as e:
- logger.error(f"EOD: Failed to get stock list: {e}")
- return
-
- # Pull daily data
- try:
- pipeline.pull_daily(symbols, today)
- except Exception as e:
- logger.error(f"EOD daily pull failed: {e}")
-
- # Pull minute data (1m, 5m, 15m, 30m, 1h)
- try:
- pipeline.pull_minute(symbols, today)
- except Exception as e:
- logger.error(f"EOD minute pull failed: {e}")
-
- # Pull weekly/monthly
- try:
- pipeline.pull_weekly_monthly(symbols, today)
- except Exception as e:
- logger.error(f"EOD weekly/monthly pull failed: {e}")
-
- logger.info(f"EOD job completed for {today.isoformat()}")
-
-
-async def health_check_job():
- """Daily health check: report database statistics."""
- logger.info("Health check running...")
- repo = KLineRepository()
- try:
- for freq_res in [repo.get_date_range(f) for f in [Freq.d1, Freq.h1, Freq.m5]]:
- pass
- logger.info(
- f"Health check: DB OK, "
- f"1d records={repo.count_records(Freq.d1)}, "
- f"1h records={repo.count_records(Freq.h1)}"
- )
- except Exception as e:
- logger.error(f"Health check failed: {e}")
-
-
-# Import at bottom to avoid circular
-from ashare_dp.core.models import Freq
-
-
-async def ema52_screening_job():
- """EOD EMA52 screening: find stocks near EMA52 for 1d and 1w.
-
- Triggered after EOD data pull. Scans all stocks, computes EMA52
- from close prices, and saves results to ema52_screening table.
- """
- from ashare_dp.config import Settings
-
- import numpy as np
- import pandas as pd
- from ashare_dp.storage.database import get_db
-
- settings = Settings()
- threshold = settings.ema52_threshold
- db = get_db()
-
- # Get all ts_codes and names
- stocks = db.query(
- "SELECT ts_code, name FROM stock_info ORDER BY ts_code"
- )
- symbols = [row[0] for row in stocks]
- symbol_names = {row[0]: row[1] for row in stocks}
-
- logger.info(
- f"EMA52 screening: {len(symbols)} stocks, "
- f"threshold=±{threshold * 100:.1f}%"
- )
-
- for freq, freq_label, bars_needed in [
- (Freq.d1, "1d", 100),
- (Freq.w1, "1w", 60),
- ]:
- try:
- from ashare_dp.storage.partitioning import partition_glob
- glob = partition_glob(freq)
- # Get last N bars per stock using ROW_NUMBER()
- sql = f"""
- WITH ranked AS (
- SELECT *, ROW_NUMBER() OVER (
- PARTITION BY ts_code ORDER BY trade_time DESC
- ) as rn
- FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true)
- )
- SELECT * FROM ranked WHERE rn <= {bars_needed}
- ORDER BY ts_code, trade_time ASC
- """
- df = db.conn.execute(sql).fetchdf()
- if df.empty:
- logger.warning(f"EMA52: no data for {freq_label}")
- continue
- except Exception as e:
- logger.error(f"EMA52: failed to read {freq_label}: {e}")
- continue
-
- df["trade_time"] = pd.to_datetime(df["trade_time"])
- df = df.sort_values(["ts_code", "trade_time"])
-
- # Get last N bars per stock, compute EMA52
- results = []
- for ts_code, group in df.groupby("ts_code"):
- if ts_code not in symbol_names:
- continue
- group = group.tail(bars_needed)
- if len(group) < 26:
- continue
-
- closes = group["close"].astype(float).values
- # Compute EMA52
- ema_values = _compute_ema(closes, 52)
- if len(ema_values) == 0:
- continue
-
- latest_close = closes[-1]
- latest_ema = ema_values[-1]
- if latest_ema <= 0:
- continue
- distance = (latest_close - latest_ema) / latest_ema
-
- # Compute daily amount in 亿 CNY
- latest_amount = float(group["amount"].iloc[-1])
- amount_yi = latest_amount / 100_000_000 # 元 → 亿
-
- if abs(distance) <= threshold:
- results.append({
- "trade_date": group["trade_date"].iloc[-1],
- "ts_code": ts_code,
- "name": symbol_names[ts_code],
- "freq": freq_label,
- "close_price": round(latest_close, 2),
- "ema52": round(latest_ema, 2),
- "distance_pct": round(distance * 100, 2),
- "amount": round(amount_yi, 2),
- })
-
- if results:
- # Sort by amount DESC (largest turnover first)
- results.sort(key=lambda r: r.get("amount", 0) or 0, reverse=True)
- # Upsert: delete old then insert
- try:
- db.conn.execute(
- "DELETE FROM ema52_screening WHERE freq = ?",
- [freq_label],
- )
- db.conn.executemany(
- """INSERT INTO ema52_screening
- (trade_date, ts_code, name, freq, close_price, ema52, distance_pct, amount)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT (trade_date, ts_code, freq) DO UPDATE SET
- close_price=excluded.close_price,
- ema52=excluded.ema52,
- distance_pct=excluded.distance_pct,
- amount=excluded.amount,
- name=excluded.name,
- updated_at=now()""",
- [(r["trade_date"], r["ts_code"], r["name"],
- r["freq"], r["close_price"], r["ema52"], r["distance_pct"], r["amount"])
- for r in results],
- )
- except Exception as e:
- logger.error(f"EMA52: failed to save {freq_label}: {e}")
- continue
-
- near = [r for r in results if abs(r["distance_pct"]) <= threshold * 100]
- logger.info(
- f"EMA52 {freq_label}: {len(results)} near "
- f"(±{threshold * 100:.1f}%), {len(near)} within threshold"
- )
-
- logger.info("EMA52 screening complete")
-
-
-def _compute_ema(values: "np.ndarray", period: int) -> "np.ndarray":
- """Compute EMA for a 1D array of values."""
- import numpy as np
- if len(values) < period:
- return np.array([])
- alpha = 2.0 / (period + 1)
- ema = np.zeros(len(values))
- ema[period - 1] = np.mean(values[:period])
- for i in range(period, len(values)):
- ema[i] = alpha * values[i] + (1 - alpha) * ema[i - 1]
- return ema
diff --git a/src/ashare_dp/signals/__init__.py b/src/ashare_dp/signals/__init__.py
new file mode 100644
index 0000000..69b7e85
--- /dev/null
+++ b/src/ashare_dp/signals/__init__.py
@@ -0,0 +1,19 @@
+"""SIGNAL INTELLIGENCE — Signal × MarketState → Expectancy. The moat.
+
+Signal detection, storage, and historical expectancy queries.
+"""
+from ashare_dp.signals.detectors import (
+ detect_ema52_signals, detect_vegas_signals,
+ detect_chan_signals, detect_orb_signals,
+ detect_gap_signals, detect_nr7_signals, detect_ib_signals,
+ run_ema52_screening,
+)
+from ashare_dp.signals.store import store_signals, count_signals
+from ashare_dp.signals.expectancy import get_expectancy
+
+__all__ = [
+ "detect_ema52_signals", "detect_vegas_signals",
+ "detect_chan_signals", "detect_orb_signals",
+ "run_ema52_screening",
+ "store_signals", "count_signals", "get_expectancy",
+]
diff --git a/src/ashare_dp/signals/detectors.py b/src/ashare_dp/signals/detectors.py
new file mode 100644
index 0000000..7bbccb9
--- /dev/null
+++ b/src/ashare_dp/signals/detectors.py
@@ -0,0 +1,696 @@
+"""Signal Detector — finds trading signals in historical K-line data.
+
+Phase 1: EMA52 crossover detection with forward outcome computation.
+Single DuckDB scan — detects, captures context, computes outcomes.
+"""
+
+from __future__ import annotations
+
+from datetime import date, timedelta
+
+import duckdb
+import pandas as pd
+from loguru import logger
+
+from ashare_dp.data.store.database import analytics_conn, kline_glob
+from ashare_dp.domain.signal import SignalType
+
+
+def detect_ema52_signals(
+ end_date: date | None = None,
+ lookback_days: int = 500,
+) -> pd.DataFrame:
+ """Detect EMA52 cross signals from daily K-line data.
+
+ Single DuckDB query that:
+ 1. Computes EMA52 per stock
+ 2. Detects cross-up and cross-down points
+ 3. Computes forward 5d/10d/20d returns
+ 4. Computes max favorable/adverse excursion
+ 5. Captures daily market breadth at signal time
+
+ Args:
+ end_date: Latest date to scan (default: latest available).
+ lookback_days: Calendar days to look back.
+
+ Returns:
+ DataFrame with columns matching SignalInstance fields.
+ """
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ if end_date is None:
+ row = conn.execute(
+ f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', "
+ f"hive_partitioning=true, union_by_name=true)"
+ ).fetchone()
+ if not row or not row[0]:
+ conn.close()
+ return pd.DataFrame()
+ end_date = date.fromisoformat(str(row[0])[:10])
+
+ start_date = end_date - timedelta(days=lookback_days)
+
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT
+ ts_code,
+ trade_date, trade_time, close, volume, open, high, low
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $start_date AND trade_date <= $end_date
+ ),
+ -- EMA52 per stock
+ with_ema AS (
+ SELECT *,
+ -- EMA52: alpha = 2/(52+1) ≈ 0.03774
+ -- Use recursive approximation via window
+ CASE
+ WHEN ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time) = 1
+ THEN close
+ ELSE 0.03774 * close + (1 - 0.03774) * LAG(close) OVER (PARTITION BY ts_code ORDER BY trade_time)
+ END AS ema52_seed
+ FROM normalized
+ ),
+ -- Better EMA52: pandas-style using cumulative product isn't easy in SQL.
+ -- Simplified: use a 52-day SMA as close-enough proxy for Phase 1.
+ with_sma AS (
+ SELECT *,
+ AVG(close) OVER (
+ PARTITION BY ts_code ORDER BY trade_time
+ ROWS BETWEEN 51 PRECEDING AND CURRENT ROW
+ ) AS sma52,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time) as rn
+ FROM normalized
+ ),
+ -- Cross detection: signal when close crosses SMA52
+ with_cross AS (
+ SELECT *,
+ LAG(close) OVER (PARTITION BY ts_code ORDER BY trade_time) AS prev_close,
+ LAG(sma52) OVER (PARTITION BY ts_code ORDER BY trade_time) AS prev_sma52,
+ -- Daily market breadth at this date
+ AVG(CASE WHEN close > open THEN 1.0 ELSE 0.0 END) OVER (PARTITION BY trade_date) AS daily_advance_ratio,
+ SUM(volume) OVER (PARTITION BY trade_date) AS daily_total_volume,
+ AVG((high - low) / NULLIF(close, 0)) OVER (PARTITION BY trade_date) AS daily_range
+ FROM with_sma
+ WHERE rn > 52 -- need SMA to be valid
+ ),
+ signals AS (
+ SELECT
+ ts_code,
+ trade_date,
+ trade_time,
+ close AS signal_price,
+ sma52,
+ daily_advance_ratio,
+ daily_range,
+ -- Cross detection
+ CASE
+ WHEN prev_close <= prev_sma52 AND close > sma52
+ THEN 'ema52_cross_up'
+ WHEN prev_close >= prev_sma52 AND close < sma52
+ THEN 'ema52_cross_down'
+ ELSE NULL
+ END AS signal_type,
+ rn
+ FROM with_cross
+ ),
+ -- Forward returns (join with future data)
+ signal_with_future AS (
+ SELECT
+ s.ts_code, s.trade_date, s.signal_price, s.sma52,
+ s.daily_advance_ratio, s.daily_range, s.signal_type, s.rn,
+ -- 5d forward
+ f5.close AS close_5d,
+ (f5.close - s.signal_price) / NULLIF(s.signal_price, 0) AS ret_5d,
+ -- 10d forward
+ f10.close AS close_10d,
+ (f10.close - s.signal_price) / NULLIF(s.signal_price, 0) AS ret_10d,
+ -- 20d forward
+ f20.close AS close_20d,
+ (f20.close - s.signal_price) / NULLIF(s.signal_price, 0) AS ret_20d
+ FROM signals s
+ LEFT JOIN with_sma f5 ON s.ts_code = f5.ts_code AND f5.rn = s.rn + 5
+ LEFT JOIN with_sma f10 ON s.ts_code = f10.ts_code AND f10.rn = s.rn + 10
+ LEFT JOIN with_sma f20 ON s.ts_code = f20.ts_code AND f20.rn = s.rn + 20
+ WHERE s.signal_type IS NOT NULL
+ ),
+ -- Max drawdown within 5d window
+ with_mdd AS (
+ SELECT sf.*,
+ -- 5d max drawdown: need to check each day between signal and 5d later
+ -- Simplified: use the min close in the window
+ MIN(w.close) OVER (
+ PARTITION BY sf.ts_code, sf.trade_date
+ ORDER BY w.trade_time
+ ROWS BETWEEN CURRENT ROW AND 4 FOLLOWING
+ ) AS min_close_5d
+ FROM signal_with_future sf
+ LEFT JOIN with_sma w
+ ON sf.ts_code = w.ts_code
+ AND w.rn BETWEEN sf.rn AND sf.rn + 5
+ )
+ SELECT DISTINCT
+ signal_type,
+ ts_code,
+ trade_date,
+ signal_price,
+ daily_advance_ratio,
+ daily_range,
+ ret_5d,
+ ret_10d,
+ ret_20d,
+ -- Max drawdown within 5d
+ CASE WHEN min_close_5d IS NOT NULL AND signal_price > 0
+ THEN (min_close_5d - signal_price) / signal_price
+ ELSE NULL END AS max_dd_5d,
+ -- Max return within 5d (approximate)
+ CASE WHEN close_5d IS NOT NULL AND signal_price > 0
+ THEN (close_5d - signal_price) / signal_price
+ ELSE NULL END AS max_return_5d
+ FROM with_mdd
+ WHERE signal_type IS NOT NULL
+ ORDER BY trade_date, ts_code
+ """
+ result = conn.execute(
+ sql.replace("$start_date", f"'{start_date.strftime('%Y-%m-%d')}'")
+ .replace("$end_date", f"'{end_date.strftime('%Y-%m-%d')}'")
+ ).fetchdf()
+ except Exception as e:
+ logger.error(f"Signal detection failed: {e}")
+ conn.close()
+ return pd.DataFrame()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return result
+
+ # Clean up
+ result = result.drop_duplicates(subset=["signal_type", "ts_code", "trade_date"])
+ logger.info(
+ f"Detected {len(result)} EMA52 signals "
+ f"({len(result[result['signal_type']=='ema52_cross_up'])} cross-up, "
+ f"{len(result[result['signal_type']=='ema52_cross_down'])} cross-down)"
+ )
+
+ return result
+
+
+# ═══════════════════════════════════════════════════
+# Vegas Channel Detection
+# ═══════════════════════════════════════════════════
+
+def detect_vegas_signals(end_date=None, lookback_days=500) -> "pd.DataFrame":
+ """Detect Vegas Channel signals from daily K-line.
+
+ Vegas Channel: EMA12, EMA50, EMA144.
+ LONG signal: price > EMA144, EMA12 > EMA50 > EMA144 (bullish tunnel),
+ price pulls back to near EMA50.
+ SHORT signal: inverse.
+ """
+ import math as _math
+ from datetime import date as _date, timedelta as _td
+
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ if end_date is None:
+ row = conn.execute(
+ f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)"
+ ).fetchone()
+ if not row or not row[0]:
+ conn.close()
+ return pd.DataFrame()
+ end_date = _date.fromisoformat(str(row[0])[:10])
+
+ start_date = end_date - _td(days=lookback_days)
+
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT ts_code, trade_date, trade_time, close, volume, high, low, open
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $start AND trade_date <= $end
+ ),
+ with_ema AS (
+ SELECT *,
+ AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) AS ema12,
+ AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 49 PRECEDING AND CURRENT ROW) AS ema50,
+ AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 143 PRECEDING AND CURRENT ROW) AS ema144,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time) as rn,
+ LAG(close) OVER (PARTITION BY ts_code ORDER BY trade_time) AS prev_close
+ FROM normalized
+ ),
+ with_signal AS (
+ SELECT *,
+ -- Vegas LONG: bullish tunnel (ema12>ema50>ema144) + price near EMA50 (within 3%)
+ CASE WHEN rn > 144
+ AND ema12 > ema50 AND ema50 > ema144
+ AND close > ema144
+ AND ABS(close - ema50) / NULLIF(ema50, 0) < 0.03
+ AND prev_close <= ema50
+ THEN 'vegas_long' ELSE NULL END AS signal_type_up,
+ -- Vegas SHORT
+ CASE WHEN rn > 144
+ AND ema12 < ema50 AND ema50 < ema144
+ AND close < ema144
+ AND ABS(close - ema50) / NULLIF(ema50, 0) < 0.03
+ AND prev_close >= ema50
+ THEN 'vegas_short' ELSE NULL END AS signal_type_down
+ FROM with_ema
+ )
+ SELECT
+ COALESCE(signal_type_up, signal_type_down) AS signal_type,
+ ts_code, trade_date, close AS signal_price,
+ AVG(CASE WHEN close > open THEN 1.0 ELSE 0.0 END) OVER (PARTITION BY trade_date) AS daily_advance_ratio,
+ AVG((high - low) / NULLIF(close, 0)) OVER (PARTITION BY trade_date) AS daily_range,
+ rn
+ FROM with_signal
+ WHERE signal_type_up IS NOT NULL OR signal_type_down IS NOT NULL
+ """
+ result = conn.execute(
+ sql.replace("$start", f"'{start_date.strftime('%Y-%m-%d')}'")
+ .replace("$end", f"'{end_date.strftime('%Y-%m-%d')}'")
+ ).fetchdf()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return result
+
+ result = result.drop_duplicates(subset=["signal_type", "ts_code", "trade_date"])
+ _compute_forward_returns(result, parquet_glob)
+ logger.info(f"Detected {len(result)} Vegas signals")
+ return result
+
+
+# ═══════════════════════════════════════════════════
+# Chan Theory Detection (MACD divergence)
+# ═══════════════════════════════════════════════════
+
+def detect_chan_signals(end_date=None, lookback_days=500) -> "pd.DataFrame":
+ """Detect Chan Theory 1buy/1sell signals via MACD divergence.
+
+ 1buy: price makes lower low but MACD makes higher low (bottom divergence).
+ 1sell: price makes higher high but MACD makes lower high (top divergence).
+ """
+ from datetime import date as _date, timedelta as _td
+
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ if end_date is None:
+ row = conn.execute(
+ f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)"
+ ).fetchone()
+ if not row or not row[0]:
+ conn.close()
+ return pd.DataFrame()
+ end_date = _date.fromisoformat(str(row[0])[:10])
+
+ start_date = end_date - _td(days=lookback_days)
+
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT ts_code, trade_date, trade_time, close, volume, high, low, open
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $start AND trade_date <= $end
+ ),
+ with_macd AS (
+ SELECT *,
+ AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 11 PRECEDING AND CURRENT ROW)
+ - AVG(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 25 PRECEDING AND CURRENT ROW) AS macd,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time) as rn
+ FROM normalized
+ ),
+ with_signal AS (
+ SELECT *,
+ AVG(macd) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 8 PRECEDING AND CURRENT ROW) AS macd_signal
+ FROM with_macd
+ ),
+ with_divergence AS (
+ SELECT *,
+ MIN(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS price_60low,
+ MAX(close) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS price_60high,
+ MIN(macd) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS macd_60low,
+ MAX(macd) OVER (PARTITION BY ts_code ORDER BY trade_time ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS macd_60high,
+ LAG(macd, 20) OVER (PARTITION BY ts_code ORDER BY trade_time) AS macd_20ago
+ FROM with_signal
+ WHERE rn > 60
+ )
+ SELECT
+ CASE
+ WHEN close <= price_60low * 1.02 AND macd > macd_60low * 1.1 AND macd > macd_20ago
+ THEN 'chan_1buy'
+ WHEN close >= price_60high * 0.98 AND macd < macd_60high * 0.9 AND macd < macd_20ago
+ THEN 'chan_1sell'
+ END AS signal_type,
+ ts_code, trade_date, close AS signal_price,
+ AVG(CASE WHEN close > open THEN 1.0 ELSE 0.0 END) OVER (PARTITION BY trade_date) AS daily_advance_ratio,
+ AVG((high - low) / NULLIF(close, 0)) OVER (PARTITION BY trade_date) AS daily_range
+ FROM with_divergence
+ WHERE (close <= price_60low * 1.02 AND macd > macd_60low * 1.1 AND macd > macd_20ago)
+ OR (close >= price_60high * 0.98 AND macd < macd_60high * 0.9 AND macd < macd_20ago)
+ """
+ result = conn.execute(
+ sql.replace("$start", f"'{start_date.strftime('%Y-%m-%d')}'")
+ .replace("$end", f"'{end_date.strftime('%Y-%m-%d')}'")
+ ).fetchdf()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return result
+
+ result = result.drop_duplicates(subset=["signal_type", "ts_code", "trade_date"])
+ _compute_forward_returns(result, parquet_glob)
+ logger.info(f"Detected {len(result)} Chan signals")
+ return result
+
+
+# ═══════════════════════════════════════════════════
+# ORB Detection (Opening Range Breakout — daily)
+# ═══════════════════════════════════════════════════
+
+def detect_orb_signals(end_date=None, lookback_days=500) -> "pd.DataFrame":
+ """Detect daily ORB signals: close breaks above yesterday's high or below low.
+
+ Simple but effective — breakouts often lead to trend days.
+ """
+ from datetime import date as _date, timedelta as _td
+
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+
+ if end_date is None:
+ row = conn.execute(
+ f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)"
+ ).fetchone()
+ if not row or not row[0]:
+ conn.close()
+ return pd.DataFrame()
+ end_date = _date.fromisoformat(str(row[0])[:10])
+
+ start_date = end_date - _td(days=lookback_days)
+
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT ts_code, trade_date, trade_time, close, volume, high, low, open
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $start AND trade_date <= $end
+ ),
+ with_prev AS (
+ SELECT *,
+ LAG(high) OVER (PARTITION BY ts_code ORDER BY trade_time) AS prev_high,
+ LAG(low) OVER (PARTITION BY ts_code ORDER BY trade_time) AS prev_low,
+ LAG(close) OVER (PARTITION BY ts_code ORDER BY trade_time) AS prev_close
+ FROM normalized
+ )
+ SELECT
+ CASE
+ WHEN close > prev_high AND prev_close <= prev_high THEN 'orb_up'
+ WHEN close < prev_low AND prev_close >= prev_low THEN 'orb_down'
+ END AS signal_type,
+ ts_code, trade_date, close AS signal_price,
+ AVG(CASE WHEN close > open THEN 1.0 ELSE 0.0 END) OVER (PARTITION BY trade_date) AS daily_advance_ratio,
+ AVG((high - low) / NULLIF(close, 0)) OVER (PARTITION BY trade_date) AS daily_range
+ FROM with_prev
+ WHERE (close > prev_high AND prev_close <= prev_high)
+ OR (close < prev_low AND prev_close >= prev_low)
+ """
+ result = conn.execute(
+ sql.replace("$start", f"'{start_date.strftime('%Y-%m-%d')}'")
+ .replace("$end", f"'{end_date.strftime('%Y-%m-%d')}'")
+ ).fetchdf()
+ finally:
+ conn.close()
+
+ if result.empty:
+ return result
+
+ result = result.drop_duplicates(subset=["signal_type", "ts_code", "trade_date"])
+ _compute_forward_returns(result, parquet_glob)
+ logger.info(f"Detected {len(result)} ORB signals")
+ return result
+
+
+# ══════════════════════════════════════════
+# Shared Forward Returns Computation
+# ══════════════════════════════════════════
+
+def _compute_forward_returns(df: "pd.DataFrame", parquet_glob: str):
+ """Join forward returns (5d, 10d, 20d) onto a signal DataFrame. Mutates in place."""
+ conn = analytics_conn()
+ try:
+ for days, col_name in [(5, "ret_5d"), (10, "ret_10d"), (20, "ret_20d")]:
+ sql = f"""
+ WITH future AS (
+ SELECT ts_code, trade_date, close,
+ LEAD(close, {days}) OVER (PARTITION BY ts_code ORDER BY trade_date) AS future_close,
+ MIN(close) OVER (PARTITION BY ts_code ORDER BY trade_date ROWS BETWEEN CURRENT ROW AND {days} FOLLOWING) AS min_close
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ )
+ SELECT ts_code, trade_date,
+ (future_close - close) / NULLIF(close, 0) AS ret,
+ (min_close - close) / NULLIF(close, 0) AS mdd
+ FROM future
+ """
+ fwd = conn.execute(sql).fetchdf()
+ fwd["trade_date"] = pd.to_datetime(fwd["trade_date"])
+ df["trade_date"] = pd.to_datetime(df["trade_date"])
+ # Merge forward returns
+ df_temp = df.merge(fwd, on=["ts_code", "trade_date"], how="left")
+ if col_name not in df.columns:
+ df[col_name] = df_temp["ret"] if "ret" in df_temp.columns else None
+ if col_name == "ret_5d":
+ if "max_dd_5d" not in df.columns:
+ df["max_dd_5d"] = df_temp["mdd"] if "mdd" in df_temp.columns else None
+ if "max_return_5d" not in df.columns:
+ df["max_return_5d"] = df_temp["ret"] if "ret" in df_temp.columns else None
+ finally:
+ conn.close()
+
+
+# ══════════════════════════════════════════
+# EMA52 Screening — CLI + scheduler shared
+# ══════════════════════════════════════════
+
+def run_ema52_screening(
+ threshold: float | None = None,
+ freqs: list | None = None,
+) -> dict:
+ """Run EMA52 screening. Shared by CLI and scheduler — no duplicated logic."""
+ import numpy as np
+ import pandas as pd
+ from ashare_dp.config import Settings
+ from ashare_dp.core.models import Freq
+ from ashare_dp.data.store.database import get_db
+ from ashare_dp.data.store.partitioning import partition_glob
+
+ settings = Settings()
+ if threshold is None:
+ threshold = settings.ema52_threshold
+ if freqs is None:
+ freqs = [(Freq.d1, "1d", 100), (Freq.w1, "1w", 60)]
+
+ with get_db(read_only=True) as db:
+ stocks = db.query("SELECT ts_code, name FROM stock_info ORDER BY ts_code")
+ symbol_names = {row[0]: row[1] for row in stocks}
+
+ logger.info(f"EMA52 screening: {len(symbol_names)} stocks, threshold=±{threshold*100:.1f}%")
+ results_by_freq = {}
+
+ for freq, freq_label, bars_needed in freqs:
+ try:
+ glob = partition_glob(freq)
+ sql = f"""
+ WITH ranked AS (
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_time DESC) as rn
+ FROM read_parquet('{glob}', hive_partitioning=true, union_by_name=true)
+ )
+ SELECT * FROM ranked WHERE rn <= {bars_needed} ORDER BY ts_code, trade_time ASC
+ """
+ import duckdb
+ c = duckdb.connect()
+ df = c.execute(sql).fetchdf() if True else None
+ c.close()
+ if df.empty:
+ logger.warning(f"EMA52: no data for {freq_label}")
+ continue
+ except Exception as e:
+ logger.error(f"EMA52: read failed {freq_label}: {e}")
+ continue
+
+ df["trade_time"] = pd.to_datetime(df["trade_time"])
+ df = df.sort_values(["ts_code", "trade_time"])
+ found = []
+
+ for ts_code, group in df.groupby("ts_code"):
+ if ts_code not in symbol_names:
+ continue
+ group = group.tail(bars_needed)
+ if len(group) < 26:
+ continue
+ closes = group["close"].astype(float).values
+ ema = _ema(closes, 52)
+ if len(ema) == 0:
+ continue
+ latest_close, latest_ema = closes[-1], ema[-1]
+ if latest_ema <= 0:
+ continue
+ dist = (latest_close - latest_ema) / latest_ema
+ if abs(dist) <= threshold:
+ found.append({
+ "trade_date": group["trade_date"].iloc[-1],
+ "ts_code": ts_code, "name": symbol_names[ts_code],
+ "freq": freq_label,
+ "close_price": round(latest_close, 2),
+ "ema52": round(latest_ema, 2),
+ "distance_pct": round(dist * 100, 2),
+ "amount": round(float(group["amount"].iloc[-1]) / 1e8, 2),
+ })
+
+ if found:
+ found.sort(key=lambda r: r.get("amount", 0) or 0, reverse=True)
+ with get_db(read_only=False) as wdb:
+ wdb.conn.execute("DELETE FROM ema52_screening WHERE freq = ?", [freq_label])
+ wdb.conn.executemany(
+ """INSERT INTO ema52_screening
+ (trade_date, ts_code, name, freq, close_price, ema52, distance_pct, amount)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (trade_date, ts_code, freq) DO UPDATE SET
+ close_price=excluded.close_price, ema52=excluded.ema52,
+ distance_pct=excluded.distance_pct, amount=excluded.amount,
+ name=excluded.name, updated_at=now()""",
+ [(r["trade_date"], r["ts_code"], r["name"], r["freq"], r["close_price"],
+ r["ema52"], r["distance_pct"], r["amount"]) for r in found],
+ )
+ logger.info(f"EMA52 {freq_label}: {len(found)} near (±{threshold*100:.1f}%)")
+ results_by_freq[freq_label] = len(found)
+
+ logger.info("EMA52 screening complete")
+ return results_by_freq
+
+
+# ══════════════════════════════════════════
+# Gap / NR7 / Inside Bar Detection
+# ══════════════════════════════════════════
+
+def detect_gap_signals(end_date=None, lookback_days=500) -> "pd.DataFrame":
+ """Detect gap-up and gap-down signals."""
+ from datetime import date as _date, timedelta as _td
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+ if end_date is None:
+ r = conn.execute(f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)").fetchone()
+ end_date = _date.fromisoformat(str(r[0])[:10]) if r and r[0] else _date.today()
+ start_date = end_date - _td(days=lookback_days)
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT ts_code, trade_date, close, open, high, low
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $s AND trade_date <= $e
+ ),
+ prev AS (
+ SELECT *, LAG(high) OVER (PARTITION BY ts_code ORDER BY trade_date) AS prev_high,
+ LAG(low) OVER (PARTITION BY ts_code ORDER BY trade_date) AS prev_low
+ FROM normalized
+ )
+ SELECT CASE WHEN open > prev_high THEN 'gap_up' WHEN open < prev_low THEN 'gap_down' END AS signal_type,
+ ts_code, trade_date, close AS signal_price
+ FROM prev WHERE (open > prev_high) OR (open < prev_low)
+ """
+ df = conn.execute(sql.replace("$s", f"'{start_date}'").replace("$e", f"'{end_date}'")).fetchdf()
+ finally:
+ conn.close()
+ if not df.empty:
+ df = df.drop_duplicates(subset=["signal_type", "ts_code", "trade_date"])
+ logger.info(f"Detected {len(df)} Gap signals")
+ return df
+
+
+def detect_nr7_signals(end_date=None, lookback_days=500) -> "pd.DataFrame":
+ """Detect NR7: narrowest range in 7 days."""
+ from datetime import date as _date, timedelta as _td
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+ if end_date is None:
+ r = conn.execute(f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)").fetchone()
+ end_date = _date.fromisoformat(str(r[0])[:10]) if r and r[0] else _date.today()
+ start_date = end_date - _td(days=lookback_days)
+ try:
+ sql = f"""
+ WITH ranges AS (
+ SELECT ts_code, trade_date, close, (high - low) AS day_range,
+ ROW_NUMBER() OVER (PARTITION BY ts_code ORDER BY trade_date) AS rn
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $s AND trade_date <= $e
+ ),
+ window_min AS (
+ SELECT *,
+ MIN(day_range) OVER (PARTITION BY ts_code ORDER BY trade_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS min_range_7,
+ COUNT(*) OVER (PARTITION BY ts_code ORDER BY trade_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS window_count
+ FROM ranges
+ )
+ SELECT 'nr7' AS signal_type, ts_code, trade_date, close AS signal_price
+ FROM window_min WHERE day_range = min_range_7 AND window_count = 7 AND rn >= 7
+ """
+ df = conn.execute(sql.replace("$s", f"'{start_date}'").replace("$e", f"'{end_date}'")).fetchdf()
+ finally:
+ conn.close()
+ if not df.empty:
+ df = df.drop_duplicates(subset=["ts_code", "trade_date"])
+ logger.info(f"Detected {len(df)} NR7 signals")
+ return df
+
+
+def detect_ib_signals(end_date=None, lookback_days=500) -> "pd.DataFrame":
+ """Detect Inside Bar: today's range inside yesterday's."""
+ from datetime import date as _date, timedelta as _td
+ parquet_glob = kline_glob()
+ conn = analytics_conn()
+ if end_date is None:
+ r = conn.execute(f"SELECT MAX(trade_date) FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)").fetchone()
+ end_date = _date.fromisoformat(str(r[0])[:10]) if r and r[0] else _date.today()
+ start_date = end_date - _td(days=lookback_days)
+ try:
+ sql = f"""
+ WITH normalized AS (
+ SELECT ts_code, trade_date, close, open, high, low
+ FROM read_parquet('{parquet_glob}', hive_partitioning=true, union_by_name=true)
+ WHERE trade_date >= $s AND trade_date <= $e
+ ),
+ prev AS (
+ SELECT *, LAG(high) OVER (PARTITION BY ts_code ORDER BY trade_date) AS prev_high,
+ LAG(low) OVER (PARTITION BY ts_code ORDER BY trade_date) AS prev_low,
+ LAG(close) OVER (PARTITION BY ts_code ORDER BY trade_date) AS prev_close
+ FROM normalized
+ )
+ SELECT CASE WHEN close > (high+low)/2.0 THEN 'ib_long' ELSE 'ib_short' END AS signal_type,
+ ts_code, trade_date, close AS signal_price
+ FROM prev WHERE high < prev_high AND low > prev_low AND prev_high IS NOT NULL
+ """
+ df = conn.execute(sql.replace("$s", f"'{start_date}'").replace("$e", f"'{end_date}'")).fetchdf()
+ finally:
+ conn.close()
+ if not df.empty:
+ df = df.drop_duplicates(subset=["ts_code", "trade_date"])
+ logger.info(f"Detected {len(df)} Inside Bar signals")
+ return df
+
+
+def _ema(values, period: int):
+ """EMA for 1D array."""
+ import numpy as np
+ if len(values) < period:
+ return np.array([])
+ a = 2.0 / (period + 1)
+ ema = np.zeros(len(values))
+ ema[period - 1] = np.mean(values[:period])
+ for i in range(period, len(values)):
+ ema[i] = a * values[i] + (1 - a) * ema[i - 1]
+ return ema
diff --git a/src/ashare_dp/signals/expectancy.py b/src/ashare_dp/signals/expectancy.py
new file mode 100644
index 0000000..f2230c7
--- /dev/null
+++ b/src/ashare_dp/signals/expectancy.py
@@ -0,0 +1,176 @@
+"""Expectancy Engine — Signal × MarketState → historical expectancy.
+
+Queries signal_instance table for signals in similar market contexts.
+This is the moat. Phase 1: working with EMA52 signals.
+Phase 2: all signal types.
+"""
+
+from __future__ import annotations
+
+import math
+from datetime import date
+
+from loguru import logger
+
+from ashare_dp.domain.context import Expectancy
+from ashare_dp.domain.state import MarketState
+from ashare_dp.data.store.database import get_db
+
+# Signal types to try in priority order when querying general expectancy
+_SIGNAL_PRIORITY = [
+ "ema52_cross_up", "ema52_cross_down",
+ "vegas_long", "chan_1buy", "orb_up",
+ "vegas_short", "chan_1sell", "orb_down",
+ "gap_up", "nr7", "ib_long",
+ "gap_down", "ib_short",
+]
+
+
+def get_expectancy(state: MarketState, signal_type: str | None = None) -> Expectancy:
+ """The single entry point for expectancy queries.
+
+ If signal_type given: query that signal's historical stats.
+ If None: try signals in priority order, return first with real samples.
+ Falls back to heuristic estimate when no historical data matches.
+ All fallback logic lives HERE — callers never chain fallbacks themselves.
+ """
+ if signal_type:
+ return query_expectancy(state, signal_type=signal_type)
+
+ for st in _SIGNAL_PRIORITY:
+ exp = query_expectancy(state, signal_type=st)
+ if exp.sample_count > 0:
+ return exp
+
+ return estimate_expectancy(state, signal_type="generic")
+
+
+def get_all_expectancies(state: MarketState) -> list[Expectancy]:
+ """Query ALL signal types and return ranked by win_rate desc.
+
+ Used by the dashboard Expectancy comparison table.
+ Signal types with 0 samples get heuristic estimates (marked by sample_count=0).
+ """
+ results = []
+ for st in _SIGNAL_PRIORITY:
+ exp = query_expectancy(state, signal_type=st, min_samples=1)
+ results.append(exp)
+
+ # Sort: real samples first (by win_rate desc), then heuristics last
+ results.sort(key=lambda e: (e.sample_count > 0, e.win_rate), reverse=True)
+ return results
+
+
+def query_expectancy(
+ state: MarketState,
+ signal_type: str = "ema52_cross_up",
+ min_samples: int = 5,
+) -> Expectancy:
+ """Query historical expectancy for a signal type in current market state.
+
+ Matches signals with similar market context (state vector cosine similarity
+ or simple threshold matching in Phase 1).
+
+ Args:
+ state: Current MarketState vector.
+ signal_type: Signal type to query (e.g., "ema52_cross_up").
+ min_samples: Minimum historical samples required for valid result.
+
+ Returns:
+ Expectancy with real historical stats, or heuristic fallback if
+ insufficient samples.
+ """
+ try:
+ with get_db(read_only=True) as db:
+ # Query all signals of this type with outcomes
+ rows = db.query(
+ """
+ SELECT
+ signal_type, ts_code, trade_date, signal_price,
+ state_breadth, state_volatility,
+ return_5d, return_10d, return_20d,
+ max_return_5d, max_drawdown_5d
+ FROM signal_instance
+ WHERE signal_type = ?
+ AND outcome_known = TRUE
+ AND return_5d IS NOT NULL
+ ORDER BY trade_date DESC
+ LIMIT 5000
+ """,
+ (signal_type,),
+ )
+ except Exception as e:
+ logger.warning(f"Expectancy query failed: {e}")
+ return estimate_expectancy(state, signal_type)
+
+ if len(rows) < min_samples:
+ logger.info(f"Only {len(rows)} samples for {signal_type}, using heuristic")
+ return estimate_expectancy(state, signal_type)
+
+ # Filter by similar market state (simple threshold match in Phase 1)
+ target_breadth = state.breadth
+ similar = []
+ for row in rows:
+ hist_breadth = float(row[4] or 0.5)
+ # Simple similarity: breadth within ±0.15
+ if abs(hist_breadth - target_breadth) < 0.15:
+ similar.append(row)
+
+ if len(similar) < max(min_samples, 1):
+ # Not enough similar contexts — use all available samples
+ similar = rows
+
+ # Compute expectancy stats
+ # Columns: 0=signal_type, 1=ts_code, 2=trade_date, 3=signal_price,
+ # 4=state_breadth, 5=state_volatility, 6=return_5d, 7=return_10d,
+ # 8=return_20d, 9=max_return_5d, 10=max_drawdown_5d
+ returns_5d = [float(r[6]) for r in similar if r[6] is not None]
+ returns_10d = [float(r[7]) for r in similar if r[7] is not None]
+ returns_20d = [float(r[8]) for r in similar if r[8] is not None]
+ drawdowns = [float(r[10]) for r in similar if r[10] is not None]
+
+ if not returns_5d:
+ return estimate_expectancy(state, signal_type)
+
+ wins = sum(1 for r in returns_5d if r > 0)
+ win_rate = wins / len(returns_5d)
+ avg_return = sum(returns_5d) / len(returns_5d)
+ max_dd = min(drawdowns) if drawdowns else avg_return * -0.5
+ avg_hold = 5.0 # Phase 1: fixed 5d horizon
+
+ confidence = min(len(similar) / 200, 1.0) # scale with sample size
+
+ return Expectancy(
+ signal_type=signal_type,
+ win_rate=round(win_rate, 4),
+ avg_return=round(avg_return, 4),
+ max_drawdown=round(max_dd, 4),
+ avg_holding_days=avg_hold,
+ sample_count=len(similar),
+ confidence=round(confidence, 4),
+ similar_states=len(similar),
+ )
+
+
+def estimate_expectancy(state: MarketState, signal_type: str = "generic") -> Expectancy:
+ """Heuristic fallback when insufficient historical data."""
+ t = state.trend
+ f = state.fear
+ l = state.liquidity
+
+ base_win = 0.50 + max(t - 0.5, 0) * 0.20 - f * 0.15 + max(l - 0.5, 0) * 0.10
+ win_rate = min(max(base_win, 0.25), 0.80)
+ avg_return = t * 0.08 - f * 0.04
+ max_dd = -(f * 0.04 + (1 - l) * 0.02)
+ avg_hold = 2.0 + t * 4.0 - f * 2.0
+
+ return Expectancy(
+ signal_type=signal_type,
+ win_rate=round(win_rate, 4),
+ avg_return=round(avg_return, 4),
+ max_drawdown=round(max_dd, 4),
+ avg_holding_days=round(avg_hold, 1),
+ sample_count=0,
+ confidence=0.0,
+ similar_states=0,
+ )
diff --git a/src/ashare_dp/signals/store.py b/src/ashare_dp/signals/store.py
new file mode 100644
index 0000000..ddbf291
--- /dev/null
+++ b/src/ashare_dp/signals/store.py
@@ -0,0 +1,72 @@
+"""Signal Instance Store — CRUD for signal_instance table.
+
+Separate from detection logic. Detection finds signals,
+store persists them. Both used by backfill pipeline.
+"""
+
+from __future__ import annotations
+
+import pandas as pd
+from loguru import logger
+
+from ashare_dp.data.store.database import get_db
+
+
+def store_signals(df: pd.DataFrame) -> int:
+ """Store detected signals in signal_instance table.
+
+ Uses ON CONFLICT DO NOTHING to safely handle re-runs.
+
+ Returns number of rows stored.
+ """
+ if df.empty:
+ return 0
+
+ with get_db(read_only=False) as db:
+ stored = 0
+ for _, row in df.iterrows():
+ try:
+ db.execute(
+ """
+ INSERT INTO signal_instance
+ (signal_type, ts_code, trade_date, signal_price,
+ state_breadth, state_volatility,
+ return_5d, return_10d, return_20d,
+ max_return_5d, max_drawdown_5d,
+ outcome_known)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE)
+ ON CONFLICT (signal_type, ts_code, trade_date) DO NOTHING
+ """,
+ (
+ row["signal_type"],
+ row["ts_code"],
+ row["trade_date"],
+ float(row["signal_price"]),
+ float(row["daily_advance_ratio"]) if pd.notna(row.get("daily_advance_ratio")) else 0.5,
+ float(row["daily_range"]) if pd.notna(row.get("daily_range")) else 0.02,
+ float(row["ret_5d"]) if pd.notna(row.get("ret_5d")) else None,
+ float(row["ret_10d"]) if pd.notna(row.get("ret_10d")) else None,
+ float(row["ret_20d"]) if pd.notna(row.get("ret_20d")) else None,
+ float(row["max_return_5d"]) if pd.notna(row.get("max_return_5d")) else None,
+ float(row["max_dd_5d"]) if pd.notna(row.get("max_dd_5d")) else None,
+ ),
+ )
+ stored += 1
+ except Exception as e:
+ logger.debug(f"Signal store failed for {row.get('ts_code')}: {e}")
+
+ logger.info(f"Stored {stored} signal instances")
+ return stored
+
+
+def count_signals(signal_type: str | None = None) -> int:
+ """Count stored signal instances, optionally filtered by type."""
+ with get_db(read_only=True) as db:
+ if signal_type:
+ rows = db.query(
+ "SELECT COUNT(*) FROM signal_instance WHERE signal_type = ?",
+ (signal_type,),
+ )
+ else:
+ rows = db.query("SELECT COUNT(*) FROM signal_instance")
+ return int(rows[0][0]) if rows else 0
diff --git a/src/ashare_dp/storage/__init__.py b/src/ashare_dp/storage/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/src/ashare_dp/storage/schema.py b/src/ashare_dp/storage/schema.py
deleted file mode 100644
index 7eddf4a..0000000
--- a/src/ashare_dp/storage/schema.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""Database schema: DDL statements for DuckDB tables and views."""
-
-from __future__ import annotations
-
-# DDL for persistent tables
-DDL_STATEMENTS = [
- """
- CREATE TABLE IF NOT EXISTS stock_info (
- ts_code VARCHAR(9) PRIMARY KEY,
- symbol VARCHAR(6) NOT NULL,
- name VARCHAR(40) NOT NULL,
- exchange VARCHAR(2) NOT NULL,
- area VARCHAR(20),
- industry VARCHAR(40),
- list_date DATE,
- delist_date DATE,
- market VARCHAR(10),
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- )
- """,
- """
- CREATE INDEX IF NOT EXISTS idx_stock_symbol ON stock_info(symbol)
- """,
- """
- CREATE INDEX IF NOT EXISTS idx_stock_exchange ON stock_info(exchange)
- """,
- """
- CREATE TABLE IF NOT EXISTS trading_calendar (
- trade_date DATE PRIMARY KEY,
- is_trading_day BOOLEAN NOT NULL DEFAULT TRUE,
- week_day TINYINT NOT NULL,
- year SMALLINT NOT NULL,
- month TINYINT NOT NULL
- )
- """,
- """
- CREATE SEQUENCE IF NOT EXISTS seq_ema52_id
- """,
- """
- CREATE TABLE IF NOT EXISTS ema52_screening (
- id BIGINT PRIMARY KEY DEFAULT nextval('seq_ema52_id'),
- trade_date DATE NOT NULL,
- ts_code VARCHAR(9) NOT NULL,
- name VARCHAR(40),
- freq VARCHAR(3) NOT NULL,
- close_price DOUBLE NOT NULL,
- ema52 DOUBLE NOT NULL,
- distance_pct DOUBLE NOT NULL,
- amount DOUBLE,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- UNIQUE(trade_date, ts_code, freq)
- )
- """,
- """
- CREATE INDEX IF NOT EXISTS idx_ema52_date ON ema52_screening(trade_date)
- """,
- # Migration: add amount column if upgrading from older schema
- """
- ALTER TABLE ema52_screening ADD COLUMN IF NOT EXISTS amount DOUBLE
- """,
-]