feat(selection): 迁移知行B1选股策略

This commit is contained in:
yuxuanhui
2026-08-08 22:41:45 +08:00
parent 0c999fb828
commit e9d06df5de
32 changed files with 2423 additions and 0 deletions
@@ -0,0 +1 @@
"""Selection bounded context for reproducible historical strategy evaluation."""
@@ -0,0 +1 @@
"""Selection application use cases."""
@@ -0,0 +1,46 @@
"""Application use case for one-stock historical Zhixing B1 evaluation."""
from __future__ import annotations
from datetime import date
from ..domain.models import SelectionEvaluation, StockHistory
from ..domain.ports import MarketDataReader, MarketDataReaderError
from ..domain.zhixing_b1 import ZhixingB1Strategy
class EvaluateZhixingB1:
"""Read one history, evaluate the pure strategy, and map read failures."""
def __init__(
self,
reader: MarketDataReader,
strategy: ZhixingB1Strategy | None = None,
) -> None:
"""Inject the market-data port and optionally a strategy instance."""
self.reader = reader
self.strategy = strategy or ZhixingB1Strategy()
def execute(self, ts_code: str, target_trade_date: date) -> SelectionEvaluation:
"""Evaluate ``ts_code`` on the exact requested trading date."""
try:
history = self.reader.load_history(ts_code, target_trade_date)
except MarketDataReaderError as exc:
return SelectionEvaluation(
ts_code=ts_code,
target_trade_date=target_trade_date,
status="data_error",
reason=str(exc),
)
return self.strategy.evaluate(history, target_trade_date)
def execute_history(
self,
history: StockHistory,
target_trade_date: date,
) -> SelectionEvaluation:
"""Evaluate an already loaded history for deterministic unit tests."""
return self.strategy.evaluate(history, target_trade_date)
@@ -0,0 +1,6 @@
# Selection bounded context
`selection` owns formula semantics and historical evaluation models for
`zhixing_b1`. Its domain imports only Pandas/NumPy and its own models/ports;
PostgreSQL remains behind `infrastructure/postgres_reader.py`. This first slice
does not expose HTTP routes or write signal records.
@@ -0,0 +1 @@
"""Pure selection domain models, indicators, ports, and strategies."""
@@ -0,0 +1,230 @@
"""TDX-style indicator primitives used by the Zhixing B1 formula.
All inputs are ascending by trading date. Rolling functions intentionally
use available observations for the early rows, while ``EVERY`` keeps its
full-window requirement. This matches the legacy formula's warm-up behavior
without using future rows.
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
import pandas as pd
def _check_window(window: int) -> None:
"""Validate a positive TDX lookback window."""
if window < 1:
raise ValueError("window must be positive")
def MA(series: pd.Series, window: int) -> pd.Series:
"""Return a simple moving average with available-row warm-up."""
_check_window(window)
return series.rolling(window=window, min_periods=1).mean()
def EMA(series: pd.Series, window: int) -> pd.Series:
"""Return an adjust-false exponential moving average."""
_check_window(window)
return series.ewm(span=window, adjust=False, min_periods=1).mean()
def LLV(series: pd.Series, window: int) -> pd.Series:
"""Return the lowest value in the trailing window."""
_check_window(window)
return series.rolling(window=window, min_periods=1).min()
def HHV(series: pd.Series, window: int) -> pd.Series:
"""Return the highest value in the trailing window."""
_check_window(window)
return series.rolling(window=window, min_periods=1).max()
def SMA(series: pd.Series, window: int, weight: int = 1) -> pd.Series:
"""Return TDX ``SMA(X,N,M)`` using its recursive weighted average."""
_check_window(window)
if weight < 0 or weight > window:
raise ValueError("weight must be between zero and window")
return series.ewm(alpha=weight / window, adjust=False, min_periods=1).mean()
def REF(series: pd.Series, periods: int) -> pd.Series:
"""Return the value ``periods`` trading rows ago."""
if periods < 0:
raise ValueError("periods must not be negative")
return series.shift(periods)
def EXIST(condition: pd.Series, window: int) -> pd.Series:
"""Return whether a condition occurred at least once in the window."""
_check_window(window)
values = condition.fillna(False).astype(bool).astype(float)
return values.rolling(window=window, min_periods=1).max().astype(bool)
def EVERY(condition: pd.Series, window: int) -> pd.Series:
"""Return whether every row in a complete trailing window is true."""
_check_window(window)
values = condition.fillna(False).astype(bool).astype(float)
return values.rolling(window=window, min_periods=window).min().fillna(0).astype(bool)
def COUNT(condition: pd.Series, window: int) -> pd.Series:
"""Count true rows in the trailing window."""
_check_window(window)
values = condition.fillna(False).astype(bool).astype(float)
return values.rolling(window=window, min_periods=1).sum()
def HHVBARS(series: pd.Series, window: int) -> pd.Series:
"""Return periods since the most recent trailing maximum."""
_check_window(window)
values = series.to_numpy(dtype=float)
result = np.full(len(values), np.nan, dtype=float)
for index in range(len(values)):
start = max(0, index - window + 1)
trailing = values[start : index + 1]
finite = np.isfinite(trailing)
if not finite.any():
continue
maximum = np.nanmax(trailing)
latest = np.flatnonzero(finite & (trailing == maximum))[-1]
result[index] = len(trailing) - 1 - int(latest)
return pd.Series(result, index=series.index, dtype=float)
def BARSLAST(condition: pd.Series) -> pd.Series:
"""Return periods since the most recent true row, or NaN before one."""
values = condition.fillna(False).astype(bool).to_numpy()
result = np.full(len(values), np.nan, dtype=float)
last_true = -1
for index, matched in enumerate(values):
if matched:
last_true = index
if last_true >= 0:
result[index] = index - last_true
return pd.Series(result, index=condition.index, dtype=float)
def CROSS(left: pd.Series, right: pd.Series) -> pd.Series:
"""Return rows where ``left`` crosses from below to at-or-above right."""
previous_left = REF(left, 1)
previous_right = REF(right, 1)
return (
previous_left.notna()
& previous_right.notna()
& left.notna()
& right.notna()
& (previous_left < previous_right)
& (left >= right)
)
def compute_kdj(frame: pd.DataFrame, window: int = 9) -> pd.DataFrame:
"""Compute ascending-data K, D and J values.
A zero high-low range is represented as NaN. K and D carry their prior
state across such a row, while J remains NaN there, preventing a flat or
incomplete bar from becoming an oversold signal.
"""
_check_window(window)
if frame.empty:
return pd.DataFrame(index=frame.index, data={"K": [], "D": [], "J": []})
low = LLV(frame["low"], window)
high = HHV(frame["high"], window)
denominator = high - low
rsv = ((frame["close"] - low) / denominator.replace(0, np.nan) * 100).to_numpy(float)
k = np.full(len(rsv), np.nan, dtype=float)
d = np.full(len(rsv), np.nan, dtype=float)
previous_k = 50.0
previous_d = 50.0
for index, value in enumerate(rsv):
if np.isfinite(value):
previous_k = (2.0 * previous_k + value) / 3.0
previous_d = (2.0 * previous_d + previous_k) / 3.0
k[index] = previous_k
d[index] = previous_d
j = 3.0 * k - 2.0 * d
return pd.DataFrame(index=frame.index, data={"K": k, "D": d, "J": j})
def compute_rsi(close: pd.Series, window: int = 3) -> pd.Series:
"""Compute TDX RSI from close prices, preserving zero-denominator NaN."""
_check_window(window)
previous = REF(close, 1)
change = close - previous
gain = change.clip(lower=0)
absolute_change = change.abs()
denominator = SMA(absolute_change, window, 1)
return SMA(gain, window, 1).div(denominator.replace(0, np.nan)).mul(100)
def compute_zhixing_lines(close: pd.Series) -> tuple[pd.Series, pd.Series]:
"""Return the formula's trend white line and 4-MA yellow line."""
white = EMA(EMA(close, 10), 10)
yellow = (MA(close, 14) + MA(close, 28) + MA(close, 57) + MA(close, 114)) / 4
return white, yellow
def is_wide_limit(code: str) -> bool:
"""Return whether a code belongs to the 20-percent-limit prefixes."""
return code.startswith(("68", "30", "4", "8", "9"))
def compute_amplitude_params(code: str, close: pd.Series | pd.DataFrame) -> tuple[float, float]:
"""Return ``(daily_range_limit, change_relaxation)`` for one history.
Ordinary stocks are widened when a more-than-15-percent historical move
appears in the available trailing 200 trading rows. The function accepts
either a close series or a frame containing ``close`` for test and caller
convenience.
"""
values = close["close"] if isinstance(close, pd.DataFrame) else close
wide = is_wide_limit(code)
if not wide and not values.empty:
ratio = values / REF(values, 1)
wide = bool(EXIST(ratio > 1.15, min(200, len(values))).iloc[-1])
return (8.0, 0.9) if wide else (5.0, 1.0)
def finite_or_none(value: object) -> float | None:
"""Convert one numeric scalar to a JSON-safe float or ``None``."""
if value is None:
return None
number = float(str(value))
return number if np.isfinite(number) else None
def serializable_metrics(values: Sequence[tuple[str, object]]) -> dict[str, float | str | None]:
"""Convert target-row metrics into a JSON-safe details mapping."""
result: dict[str, float | str | None] = {}
for key, value in values:
if isinstance(value, str) or value is None:
result[key] = value
else:
result[key] = finite_or_none(value)
return result
@@ -0,0 +1,143 @@
"""Stable, storage-independent models used by the selection domain."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import date
from enum import StrEnum
from math import isfinite
from typing import Literal
def _validate_number(value: float | None, field_name: str) -> None:
"""Reject infinities while allowing ``None`` for incomplete source rows."""
if value is not None and not isfinite(value):
raise ValueError(f"{field_name} must be finite or None")
@dataclass(frozen=True, slots=True)
class SelectionBar:
"""One qfq daily OHLCV row in the strategy's numeric vocabulary.
Source nulls are retained as ``None`` so a missing target price cannot be
silently converted into a signal. The PostgreSQL adapter performs the
Decimal-to-float conversion at this boundary.
"""
trade_date: date
open: float | None
high: float | None
low: float | None
close: float | None
volume: float | None
def __post_init__(self) -> None:
"""Validate that source numbers are finite when present."""
for field_name in ("open", "high", "low", "close", "volume"):
_validate_number(getattr(self, field_name), field_name)
@property
def vol(self) -> float | None:
"""Return the database-compatible alias for ``volume``."""
return self.volume
@dataclass(frozen=True, slots=True)
class SelectionDailyBasic:
"""Same-day optional valuation and liquidity facts."""
trade_date: date
turnover_rate: float | None = None
total_mv: float | None = None
def __post_init__(self) -> None:
"""Validate optional numerical facts without inventing missing data."""
_validate_number(self.turnover_rate, "turnover_rate")
_validate_number(self.total_mv, "total_mv")
@dataclass(frozen=True, slots=True)
class StockHistory:
"""A stock's ascending qfq bars and date-indexed daily-basic facts."""
ts_code: str
name: str
bars: tuple[SelectionBar, ...] = field(default_factory=tuple)
daily_basic: Mapping[date, SelectionDailyBasic] = field(
default_factory=lambda: dict[date, SelectionDailyBasic]()
)
@property
def daily_basics(self) -> Mapping[date, SelectionDailyBasic]:
"""Return the plural alias used by some callers."""
return self.daily_basic
class ZhixingB1Category(StrEnum):
"""The seven independent, persistence-ready B1 sub-signal categories."""
OVERSOLD_TURN = "zhixing_b1_oversold_turn"
OVERSOLD_VOLUME = "zhixing_b1_oversold_volume"
ORIGINAL_B1 = "zhixing_b1_original_b1"
EXTREME_VOLUME = "zhixing_b1_extreme_volume"
PULLBACK_WHITE = "zhixing_b1_pullback_white"
PULLBACK_SUPER = "zhixing_b1_pullback_super"
PULLBACK_YELLOW = "zhixing_b1_pullback_yellow"
@dataclass(frozen=True, slots=True)
class SelectionSignal:
"""One explainable B1 hit with a stable identity."""
ts_code: str
name: str
target_trade_date: date
strategy: Literal["zhixing_b1"]
category: ZhixingB1Category
close: float
details: Mapping[str, float | str | None] = field(
default_factory=lambda: dict[str, float | str | None]()
)
@property
def identity(self) -> tuple[str, date, str, str]:
"""Return the future persistence key for this signal."""
return (
self.ts_code,
self.target_trade_date,
self.strategy,
self.category.value,
)
SelectionEvaluationStatus = Literal[
"selected",
"no_signal",
"insufficient_history",
"missing_target_bar",
"data_error",
]
@dataclass(frozen=True, slots=True)
class SelectionEvaluation:
"""Result of evaluating one stock on one explicit trade date."""
ts_code: str
target_trade_date: date
status: SelectionEvaluationStatus
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
reason: str | None = None
@property
def selected(self) -> bool:
"""Return whether at least one independent sub-signal matched."""
return self.status == "selected"
@@ -0,0 +1,18 @@
"""Ports that keep selection formulas independent from storage technology."""
from __future__ import annotations
from datetime import date
from typing import Protocol
from .models import StockHistory
class MarketDataReaderError(RuntimeError):
"""A market-data adapter could not complete a read."""
class MarketDataReader(Protocol):
"""Read qfq history sufficient for one historical strategy evaluation."""
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory: ...
@@ -0,0 +1,565 @@
"""Formula-level implementation of the seven Zhixing B1 sub-signals."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import date
import numpy as np
import pandas as pd
from .indicators import (
BARSLAST,
COUNT,
CROSS,
EVERY,
HHV,
LLV,
MA,
REF,
compute_amplitude_params,
compute_kdj,
compute_rsi,
compute_zhixing_lines,
serializable_metrics,
)
from .models import (
SelectionEvaluation,
SelectionSignal,
StockHistory,
ZhixingB1Category,
)
ZHIXING_B1_SIGNAL_ORDER: tuple[ZhixingB1Category, ...] = (
ZhixingB1Category.OVERSOLD_TURN,
ZhixingB1Category.OVERSOLD_VOLUME,
ZhixingB1Category.ORIGINAL_B1,
ZhixingB1Category.EXTREME_VOLUME,
ZhixingB1Category.PULLBACK_WHITE,
ZhixingB1Category.PULLBACK_SUPER,
ZhixingB1Category.PULLBACK_YELLOW,
)
_SIGNAL_LABELS: Mapping[ZhixingB1Category, str] = {
ZhixingB1Category.OVERSOLD_TURN: "超卖缩量拐头B",
ZhixingB1Category.OVERSOLD_VOLUME: "超卖缩量B",
ZhixingB1Category.ORIGINAL_B1: "原始B1",
ZhixingB1Category.EXTREME_VOLUME: "超卖超缩量B",
ZhixingB1Category.PULLBACK_WHITE: "回踩白线B",
ZhixingB1Category.PULLBACK_SUPER: "回踩超级B",
ZhixingB1Category.PULLBACK_YELLOW: "回踩黄线B",
}
MINIMUM_HISTORY = 114
def _not_big_green_bar(
volume: np.ndarray,
open_price: np.ndarray,
close: np.ndarray,
previous_close: np.ndarray,
window: int = 40,
) -> tuple[pd.Series, pd.Series]:
"""Return ``(not_big_green, big_green_far)`` for every trading row."""
not_big_green = np.ones(len(volume), dtype=bool)
big_green_far = np.zeros(len(volume), dtype=bool)
for index in range(len(volume)):
start = max(0, index - window + 1)
trailing = volume[start : index + 1]
finite = np.isfinite(trailing)
if not finite.any():
not_big_green[index] = False
continue
maximum = np.nanmax(trailing)
local_positions = np.flatnonzero(finite & (trailing == maximum))
maximum_index = start + int(local_positions[-1])
periods_ago = index - maximum_index
is_not_bearish = (
close[maximum_index] >= previous_close[maximum_index]
or close[maximum_index] >= open_price[maximum_index]
)
not_big_green[index] = is_not_bearish
big_green_far[index] = not is_not_bearish and periods_ago >= 15
index = pd.RangeIndex(len(volume))
return (
pd.Series(not_big_green, index=index),
pd.Series(big_green_far, index=index),
)
def _safe_percentage(numerator: pd.Series, denominator: pd.Series) -> pd.Series:
"""Divide into percentages while making zero denominators explicit NaN."""
return numerator.div(denominator.replace(0, np.nan)).mul(100)
def prepare_zhixing_b1_indicators(frame: pd.DataFrame, code: str) -> pd.DataFrame:
"""Prepare all formula intermediates for ascending OHLCV rows.
Args:
frame: DataFrame with ``open``, ``high``, ``low``, ``close`` and
``volume`` columns, ordered from old to new.
code: Tushare-style stock code used for width-limit parameters.
Returns:
A copy containing named, testable intermediate formula values.
Raises:
KeyError: If an OHLCV column is absent.
"""
result = frame.copy()
close = result["close"].astype(float)
high = result["high"].astype(float)
low = result["low"].astype(float)
open_price = result["open"].astype(float)
volume = result["volume"].astype(float)
white, yellow = compute_zhixing_lines(close)
result["trend_white"] = white
result["trend_yellow"] = yellow
result["bbi"] = (MA(close, 3) + MA(close, 6) + MA(close, 12) + MA(close, 24)) / 4
short_low = LLV(low, 3)
short_high = HHV(close, 3)
long_low = LLV(low, 21)
long_high = HHV(close, 21)
result["short_oscillator"] = _safe_percentage(close - short_low, short_high - short_low)
result["long_oscillator"] = _safe_percentage(close - long_low, long_high - long_low)
kdj = compute_kdj(result, window=9)
result[["k", "d", "j"]] = kdj[["K", "D", "J"]]
result["rsi"] = compute_rsi(close, window=3)
amplitude_range, relaxation = compute_amplitude_params(code, close)
result["amplitude_range"] = amplitude_range
result["relaxation"] = relaxation
result["daily_amplitude"] = _safe_percentage(high - low, low)
previous_close = REF(close, 1)
result["daily_change"] = _safe_percentage((close - previous_close).abs(), previous_close)
result["daily_change"] = result["daily_change"] * relaxation
result["up_cross"] = (close > previous_close) & (
_safe_percentage((close - open_price).abs(), open_price) * relaxation < 1.8
)
highest_volume_20 = HHV(volume, 20)
highest_volume_30 = HHV(volume, 30)
highest_volume_50 = HHV(volume, 50)
result["low_volume"] = (volume < highest_volume_20 * 0.416) | (volume < highest_volume_50 / 3)
result["pullback_low_volume"] = (volume < highest_volume_20 * 0.45) | (
volume < highest_volume_50 / 3
)
result["moderate_low_volume"] = (volume < highest_volume_20 * 0.618) | (
volume < highest_volume_50 / 3
)
result["extreme_low_volume"] = (volume < highest_volume_30 / 4) | (
volume < highest_volume_50 / 6
)
not_big_green, big_green_far = _not_big_green_bar(
volume.to_numpy(float),
open_price.to_numpy(float),
close.to_numpy(float),
previous_close.fillna(close).to_numpy(float),
)
result["not_big_green"] = not_big_green.to_numpy()
result["big_green_far"] = big_green_far.to_numpy()
recent_low = LLV(low, 20)
recent_high = HHV(high, 20)
distant_low = LLV(low, 50)
distant_high = HHV(high, 50)
result["recent_amplitude"] = _safe_percentage(recent_high - recent_low, recent_low)
result["distant_amplitude"] = _safe_percentage(distant_high - distant_low, distant_low)
result["super_change"] = result["recent_amplitude"] >= 60
short = result["short_oscillator"]
long = result["long_oscillator"]
result["single_pin"] = (short <= 20) & (long >= 75) | ((long - short) >= 70)
result["treasure_bowl"] = (
(COUNT(long >= 75, 8) >= 6) & (COUNT(short <= 70, 7) >= 4) & (COUNT(short <= 50, 8) >= 1)
)
result["double_trident"] = (
EVERY(long >= 75, 8) & (COUNT(short <= 50, 6) >= 2) & (COUNT(short <= 20, 7) >= 1)
)
result["red_fat_green_thin"] = (COUNT(close >= open_price, 15) > 7) | (
COUNT(close > previous_close, 11) > 5
)
result["wash_change"] = (
(COUNT(result["single_pin"], 10) >= 2) | result["treasure_bowl"] | result["double_trident"]
)
result["recent_change"] = (result["recent_amplitude"] >= 15) | (
_safe_percentage(HHV(high, 12) - LLV(low, 14), LLV(low, 14)) >= 11
)
result["distant_change"] = result["distant_amplitude"] >= 30
result["uptrend"] = (white >= yellow) & (
(close >= yellow) | ((close > yellow * 0.975) & (close > open_price))
)
result["strong_trend"] = (
EVERY(yellow >= REF(yellow, 1) * 0.999, 13)
& (white >= REF(white, 1))
& EVERY(white > yellow, 20)
& EVERY(white >= REF(white, 1), 11)
& result["red_fat_green_thin"]
)
result["super_bull"] = (
(
EVERY(result["bbi"] >= REF(result["bbi"], 1) * 0.999, 20)
| (COUNT(result["bbi"] >= REF(result["bbi"], 1), 25) >= 23)
)
& ((result["recent_amplitude"] >= 30) | (result["distant_amplitude"] > 80))
& (BARSLAST(CROSS(close, yellow)) > 12)
)
result["white_distance"] = _safe_percentage((close - white).abs(), close)
result["low_white_distance"] = _safe_percentage((low - white).abs(), white)
result["bbi_distance"] = _safe_percentage((close - result["bbi"]).abs(), close)
result["low_bbi_distance"] = _safe_percentage((low - result["bbi"]).abs(), result["bbi"])
result["yellow_distance"] = _safe_percentage((close - yellow).abs(), yellow)
result["white_pullback"] = (
((close >= white) & (result["white_distance"] <= 2))
| ((close < white) & (result["white_distance"] < 0.8))
| (
(close >= result["bbi"])
& (result["bbi_distance"] < 2.5)
& (result["low_bbi_distance"] < 1)
& (result["white_distance"] <= 3)
& (result["daily_change"] < 1)
& (close > previous_close)
)
)
result["white_support"] = (close >= white) & (result["white_distance"] < 1.5)
result["strong_pullback"] = (
((result["low_white_distance"] < 1) | (result["low_bbi_distance"] < 0.5))
& (close > white)
& (result["white_distance"] <= 3.5)
)
result["yellow_pullback"] = (
(close >= yellow)
& (
(result["yellow_distance"] <= 1.5)
| ((result["yellow_distance"] <= 2) & (result["daily_change"] < 1))
)
) | ((close < yellow) & (result["yellow_distance"] <= 0.8))
return result
def compute_signal_masks(frame: pd.DataFrame) -> dict[ZhixingB1Category, pd.Series]:
"""Return all seven independent signal masks for prepared indicators.
The function deliberately returns every mask separately. Callers must
not collapse them into one mask before constructing signals.
"""
required = {
"uptrend",
"rsi",
"j",
"amplitude_range",
"daily_amplitude",
"daily_change",
"up_cross",
"not_big_green",
"big_green_far",
"recent_change",
"distant_change",
"wash_change",
"trend_white",
"trend_yellow",
"low_volume",
"moderate_low_volume",
"extreme_low_volume",
"recent_amplitude",
"distant_amplitude",
"super_change",
"strong_trend",
"super_bull",
"white_distance",
"bbi_distance",
"yellow_distance",
"white_pullback",
"white_support",
"strong_pullback",
"yellow_pullback",
"low_white_distance",
"low_bbi_distance",
"bbi",
"open",
"close",
"low",
"volume",
}
missing = sorted(required.difference(frame.columns))
if missing:
raise ValueError(f"prepared indicators missing columns: {', '.join(missing)}")
rsi = frame["rsi"]
j = frame["j"]
rsi_j = rsi + j
previous_rsi = REF(rsi, 1)
previous_j = REF(j, 1)
previous_volume = REF(frame["volume"], 1)
change_trigger = frame["recent_change"] | frame["distant_change"] | frame["wash_change"]
not_green = frame["not_big_green"] | frame["big_green_far"]
daily_range = frame["daily_amplitude"]
daily_change = frame["daily_change"]
close = frame["close"]
open_price = frame["open"]
oversold_turn = (
frame["uptrend"]
& ((rsi - 15) >= previous_rsi)
& ((previous_rsi < 20) | (previous_j < 14))
& (daily_range < frame["amplitude_range"] + 0.5)
& ((daily_change < 2.3) | (frame["up_cross"] & (daily_change < 4)))
& not_green
& change_trigger
& (close >= frame["trend_yellow"])
)
oversold_volume = (
frame["uptrend"]
& ((j < 14) | (rsi < 23))
& ((rsi_j < 55) | (j == LLV(j, 20)))
& (daily_range < frame["amplitude_range"])
& ((daily_change < 2.5) | frame["up_cross"])
& not_green
& (frame["low_volume"] | (frame["moderate_low_volume"] & (daily_change < 1)))
& change_trigger
)
original_b1 = (
(frame["trend_white"] > frame["trend_yellow"])
& (close >= frame["trend_yellow"] * 0.99)
& (frame["trend_yellow"] >= REF(frame["trend_yellow"], 1))
& ((j < 13) | (rsi < 21))
& (rsi_j < LLV(rsi_j, 15) * 1.5)
& frame["moderate_low_volume"]
& not_green
& (
(_safe_percentage((close - open_price).abs(), open_price) < 1.5)
| frame["extreme_low_volume"]
| (
frame["moderate_low_volume"]
& (frame["volume"] < LLV(frame["volume"], 20) * 1.1)
& (j == LLV(j, 20))
)
| (
frame["moderate_low_volume"]
& (
(frame["white_distance"] < 1.8)
| (frame["bbi_distance"] < 1.5)
| (frame["yellow_distance"] < 2.8)
)
)
)
& change_trigger
)
extreme_volume = (
frame["uptrend"]
& ((j < 14) | (rsi < 23))
& (rsi_j < 60)
& (frame["distant_amplitude"] >= 45)
& (
(daily_range < frame["amplitude_range"])
| (
frame["super_change"]
& (daily_range < frame["amplitude_range"] + 3.2)
& (close > open_price)
& (close > frame["trend_white"])
)
)
& (
(
(close < open_price)
& (frame["volume"] < previous_volume)
& (close >= frame["trend_yellow"])
)
| (close >= open_price)
)
& ((daily_change < 2) | frame["up_cross"])
& not_green
& frame["extreme_low_volume"]
& change_trigger
)
pullback_white = (
frame["strong_trend"]
& ((j < 30) | (rsi < 40) | frame["wash_change"])
& (rsi_j < 70)
& (
(daily_range < frame["amplitude_range"] + 0.5)
| (frame["white_distance"] < 1)
| (frame["bbi_distance"] < 1)
)
& frame["white_pullback"]
& ((daily_change < 2) | ((daily_change < 5) & frame["white_support"]))
& not_green
& frame["pullback_low_volume"]
& change_trigger
& (frame["low"] <= REF(close, 1))
)
pullback_super = (
frame["super_bull"]
& ((j < 35) | (rsi < 45) | frame["wash_change"])
& (rsi_j < 80)
& (rsi_j == LLV(rsi_j, 25))
& (daily_range < frame["amplitude_range"] + 1)
& ((daily_change < 2.5) | (frame["white_distance"] < 2))
& frame["strong_pullback"]
& not_green
& change_trigger
& frame["moderate_low_volume"]
)
pullback_yellow = (
(frame["trend_white"] >= frame["trend_yellow"])
& (close >= frame["trend_yellow"] * 0.975)
& ((j < 13) | (rsi < 18))
& frame["yellow_pullback"]
& not_green
& (
frame["low_volume"]
| (frame["moderate_low_volume"] & ((j == LLV(j, 20)) | (rsi == LLV(rsi, 14))))
)
& (frame["trend_yellow"] >= REF(frame["trend_yellow"], 1) * 0.997)
& (MA(close, 60) >= REF(MA(close, 60), 1))
& (frame["recent_amplitude"] >= 11.9)
& (frame["distant_amplitude"] >= 19.5)
)
return {
ZhixingB1Category.OVERSOLD_TURN: oversold_turn.fillna(False).astype(bool),
ZhixingB1Category.OVERSOLD_VOLUME: oversold_volume.fillna(False).astype(bool),
ZhixingB1Category.ORIGINAL_B1: original_b1.fillna(False).astype(bool),
ZhixingB1Category.EXTREME_VOLUME: extreme_volume.fillna(False).astype(bool),
ZhixingB1Category.PULLBACK_WHITE: pullback_white.fillna(False).astype(bool),
ZhixingB1Category.PULLBACK_SUPER: pullback_super.fillna(False).astype(bool),
ZhixingB1Category.PULLBACK_YELLOW: pullback_yellow.fillna(False).astype(bool),
}
@dataclass(frozen=True, slots=True)
class ZhixingB1Strategy:
"""Evaluate all seven B1 sub-signals for a specified historical date."""
name: str = "zhixing_b1"
def evaluate(self, history: StockHistory, target_trade_date: date) -> SelectionEvaluation:
"""Return selected, no-signal, warm-up, or missing-target state.
Only bars through ``target_trade_date`` are passed into the formulas;
future rows supplied by a reader cannot affect the historical result.
"""
bars_by_date = {bar.trade_date: bar for bar in history.bars}
target_bar = bars_by_date.get(target_trade_date)
if target_bar is None or any(
value is None
for value in (
target_bar.open,
target_bar.high,
target_bar.low,
target_bar.close,
target_bar.volume,
)
):
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"missing_target_bar",
reason="target trade date has no complete qfq daily bar",
)
selected_bars = tuple(
sorted(
(bar for bar in bars_by_date.values() if bar.trade_date <= target_trade_date),
key=lambda bar: bar.trade_date,
)
)
if len(selected_bars) < MINIMUM_HISTORY:
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"insufficient_history",
reason=f"need at least {MINIMUM_HISTORY} ascending bars before evaluation",
)
frame = pd.DataFrame(
{
"trade_date": [bar.trade_date for bar in selected_bars],
"open": [bar.open for bar in selected_bars],
"high": [bar.high for bar in selected_bars],
"low": [bar.low for bar in selected_bars],
"close": [bar.close for bar in selected_bars],
"volume": [bar.volume for bar in selected_bars],
}
)
if bool(frame.isna().to_numpy().any()):
target_index = frame.index[frame["trade_date"] == target_trade_date]
target_incomplete = False
if not target_index.empty:
target_incomplete = bool(
frame.loc[target_index[0], ["open", "high", "low", "close", "volume"]]
.isna()
.to_numpy()
.any()
)
if target_index.empty or target_incomplete:
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"missing_target_bar",
reason="target trade date has incomplete qfq OHLCV values",
)
prepared = prepare_zhixing_b1_indicators(frame, history.ts_code)
masks = compute_signal_masks(prepared)
target_index = int(prepared.index[prepared["trade_date"] == target_trade_date][0])
matched = tuple(
category
for category in ZHIXING_B1_SIGNAL_ORDER
if bool(masks[category].iloc[target_index])
)
if not matched:
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"no_signal",
reason="no Zhixing B1 sub-signal matched the target row",
)
row = prepared.iloc[target_index]
details = serializable_metrics(
(
("sub_signal", ";".join(_SIGNAL_LABELS[category] for category in matched)),
("j", row["j"]),
("rsi", row["rsi"]),
("trend_white", row["trend_white"]),
("trend_yellow", row["trend_yellow"]),
("daily_amplitude", row["daily_amplitude"]),
("daily_change", row["daily_change"]),
("volume", row["volume"]),
)
)
signals = tuple(
SelectionSignal(
ts_code=history.ts_code,
name=history.name,
target_trade_date=target_trade_date,
strategy="zhixing_b1",
category=category,
close=float(row["close"]),
details=details,
)
for category in matched
)
return SelectionEvaluation(
history.ts_code,
target_trade_date,
"selected",
signals=signals,
)
def select(self, history: StockHistory, target_trade_date: date) -> tuple[SelectionSignal, ...]:
"""Return only signals for callers that do not need evaluation status."""
return self.evaluate(history, target_trade_date).signals
@@ -0,0 +1 @@
"""Selection infrastructure adapters."""
@@ -0,0 +1,158 @@
"""Read-only PostgreSQL adapter for selection history."""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import cast
import psycopg
from ....bootstrap.config import Settings
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
from ..domain.ports import MarketDataReaderError
class SelectionReaderError(MarketDataReaderError):
"""Database read failure with stock and target-date context."""
_HISTORY_QUERY = """
SELECT
bar.ts_code,
stock.name,
bar.trade_date,
bar.open,
bar.high,
bar.low,
bar.close,
bar.vol,
basic.turnover_rate,
basic.total_mv
FROM market_daily_bar AS bar
LEFT JOIN market_stock AS stock
ON stock.ts_code = bar.ts_code
LEFT JOIN market_daily_basic AS basic
ON basic.ts_code = bar.ts_code
AND basic.trade_date = bar.trade_date
WHERE bar.ts_code = %s
AND bar.source_adj = 'qfq'
AND bar.trade_date <= %s
ORDER BY bar.trade_date ASC
"""
def _as_date(value: object) -> date:
"""Convert a PostgreSQL date-like scalar to a date."""
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return date.fromisoformat(str(value)[:10])
def _as_float(value: object) -> float | None:
"""Convert nullable PostgreSQL numerics to finite floats."""
if value is None:
return None
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError) as exc:
raise ValueError(f"invalid market-data numeric value: {value!r}") from exc
if number.is_nan():
return None
if not number.is_finite():
raise ValueError(f"market-data numeric value must be finite: {value!r}")
return float(number)
class PostgresMarketDataReader:
"""Load qfq bars and same-day basic facts without writing market data."""
def __init__(self, settings: Settings | str) -> None:
"""Create a reader from injected settings or a compatible URL string."""
self.database_url = settings.database_url if isinstance(settings, Settings) else settings
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
"""Read all retained qfq rows through the explicit target date.
Args:
ts_code: Tushare stock identifier.
target_trade_date: Historical date to which rows are truncated.
Returns:
A sorted ``StockHistory``. An empty history is a normal missing
target-data result and is interpreted by the application layer.
Raises:
SelectionReaderError: If PostgreSQL cannot complete the read.
ValueError: If a returned date or numeric field is malformed.
"""
try:
with psycopg.connect(self.database_url) as connection:
rows = connection.execute(
_HISTORY_QUERY,
(ts_code, target_trade_date),
).fetchall()
except psycopg.Error as exc:
raise SelectionReaderError(
f"failed to load market history for {ts_code} at {target_trade_date.isoformat()}"
) from exc
bars: dict[date, SelectionBar] = {}
daily_basic: dict[date, SelectionDailyBasic] = {}
name = ""
for raw_row in rows:
row = cast(tuple[object, ...], raw_row)
row_code, row_name, bar, basic = self._map_row(row, ts_code)
if row_code != ts_code:
raise ValueError(f"reader returned unexpected stock code: {row_code}")
name = row_name or name
if bar.trade_date <= target_trade_date:
bars[bar.trade_date] = bar
daily_basic[bar.trade_date] = basic
return StockHistory(
ts_code=ts_code,
name=name,
bars=tuple(bars[trade_date] for trade_date in sorted(bars)),
daily_basic={trade_date: daily_basic[trade_date] for trade_date in sorted(daily_basic)},
)
@staticmethod
def _map_row(
row: tuple[object, ...],
expected_code: str,
) -> tuple[str, str, SelectionBar, SelectionDailyBasic]:
"""Map the current query row, tolerating a legacy test row without name."""
if len(row) >= 10:
code, raw_name, raw_date = row[0], row[1], row[2]
values = row[3:]
elif len(row) >= 9:
code, raw_name, raw_date = row[0], "", row[1]
values = row[2:]
else:
raise ValueError("market history row has too few columns")
row_code = str(code or expected_code)
name = str(raw_name or "")
trade_date = _as_date(raw_date)
if len(values) < 7:
raise ValueError("market history row is missing OHLCV/basic columns")
bar = SelectionBar(
trade_date=trade_date,
open=_as_float(values[0]),
high=_as_float(values[1]),
low=_as_float(values[2]),
close=_as_float(values[3]),
volume=_as_float(values[4]),
)
basic = SelectionDailyBasic(
trade_date=trade_date,
turnover_rate=_as_float(values[5]),
total_mv=_as_float(values[6]),
)
return row_code, name, bar, basic
@@ -0,0 +1 @@
"""Transport adapters for selection; intentionally empty in the first slice."""