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
+3
View File
@@ -7,6 +7,8 @@ requires-python = ">=3.12,<3.13"
dependencies = [
"alembic>=1.18.0",
"fastapi>=0.141.1",
"numpy>=2.4.0",
"pandas>=2.3.3",
"psycopg[binary]>=3.3.2",
"pydantic-settings>=2.14.2",
"sqlalchemy>=2.0.46",
@@ -17,6 +19,7 @@ dependencies = [
[dependency-groups]
dev = [
"httpx2>=2.9.1",
"pandas-stubs>=2.3.2.250926",
"pyright>=1.1.411",
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
@@ -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."""
@@ -0,0 +1,11 @@
# Zhixing B1 fixed fixtures
These are small, deterministic artificial qfq OHLCV histories used to verify
ordinary and wide-limit amplitude parameters without importing the legacy
project at test time. The rows are calendar-spaced only to keep the fixture
readable; the formula treats them as ascending trading observations.
`multi_signal.json` documents the independent-mask orchestration case. The
unit test forces all seven masks on one prepared target row, which is
intentional: the legacy result set did not contain a trustworthy historical
same-day multi-hit sample, while the new contract requires retaining all hits.
@@ -0,0 +1,31 @@
{
"ordinary": {
"file": "ordinary.csv",
"ts_code": "000001.SZ",
"target_trade_date": "2023-04-25",
"status": "no_signal",
"categories": [],
"note": "人工上升序列,验证普通代码使用 5% 振幅区间;不代表历史推荐结果。"
},
"wide_limit": {
"file": "wide_limit.csv",
"ts_code": "300001.SZ",
"target_trade_date": "2023-04-25",
"status": "no_signal",
"categories": [],
"note": "人工上升序列,验证 30 开头代码使用 8% 振幅区间;不代表历史推荐结果。"
},
"multi_signal": {
"status": "selected",
"categories": [
"zhixing_b1_oversold_turn",
"zhixing_b1_oversold_volume",
"zhixing_b1_original_b1",
"zhixing_b1_extreme_volume",
"zhixing_b1_pullback_white",
"zhixing_b1_pullback_super",
"zhixing_b1_pullback_yellow"
],
"note": "人工构造的同日多命中契约;测试通过独立 mask 注入验证不 break。"
}
}
@@ -0,0 +1,116 @@
trade_date,open,high,low,close,volume
2023-01-01,9.9600,10.1200,9.8800,10.0000,1000.00
2023-01-02,9.9800,10.1400,9.9000,10.0200,1030.00
2023-01-03,10.0000,10.1600,9.9200,10.0400,1060.00
2023-01-04,10.0200,10.1800,9.9400,10.0600,1090.00
2023-01-05,10.0400,10.2000,9.9600,10.0800,1120.00
2023-01-06,10.0600,10.2200,9.9800,10.1000,1150.00
2023-01-07,10.0800,10.2400,10.0000,10.1200,1180.00
2023-01-08,10.1000,10.2600,10.0200,10.1400,1000.00
2023-01-09,10.1200,10.2800,10.0400,10.1600,1030.00
2023-01-10,10.1400,10.3000,10.0600,10.1800,1060.00
2023-01-11,10.1600,10.3200,10.0800,10.2000,1090.00
2023-01-12,10.1800,10.3400,10.1000,10.2200,1120.00
2023-01-13,10.2000,10.3600,10.1200,10.2400,1150.00
2023-01-14,10.2200,10.3800,10.1400,10.2600,1180.00
2023-01-15,10.2400,10.4000,10.1600,10.2800,1000.00
2023-01-16,10.2600,10.4200,10.1800,10.3000,1030.00
2023-01-17,10.2800,10.4400,10.2000,10.3200,1060.00
2023-01-18,10.3000,10.4600,10.2200,10.3400,1090.00
2023-01-19,10.3200,10.4800,10.2400,10.3600,1120.00
2023-01-20,10.3400,10.5000,10.2600,10.3800,1150.00
2023-01-21,10.3600,10.5200,10.2800,10.4000,1180.00
2023-01-22,10.3800,10.5400,10.3000,10.4200,1000.00
2023-01-23,10.4000,10.5600,10.3200,10.4400,1030.00
2023-01-24,10.4200,10.5800,10.3400,10.4600,1060.00
2023-01-25,10.4400,10.6000,10.3600,10.4800,1090.00
2023-01-26,10.4600,10.6200,10.3800,10.5000,1120.00
2023-01-27,10.4800,10.6400,10.4000,10.5200,1150.00
2023-01-28,10.5000,10.6600,10.4200,10.5400,1180.00
2023-01-29,10.5200,10.6800,10.4400,10.5600,1000.00
2023-01-30,10.5400,10.7000,10.4600,10.5800,1030.00
2023-01-31,10.5600,10.7200,10.4800,10.6000,1060.00
2023-02-01,10.5800,10.7400,10.5000,10.6200,1090.00
2023-02-02,10.6000,10.7600,10.5200,10.6400,1120.00
2023-02-03,10.6200,10.7800,10.5400,10.6600,1150.00
2023-02-04,10.6400,10.8000,10.5600,10.6800,1180.00
2023-02-05,10.6600,10.8200,10.5800,10.7000,1000.00
2023-02-06,10.6800,10.8400,10.6000,10.7200,1030.00
2023-02-07,10.7000,10.8600,10.6200,10.7400,1060.00
2023-02-08,10.7200,10.8800,10.6400,10.7600,1090.00
2023-02-09,10.7400,10.9000,10.6600,10.7800,1120.00
2023-02-10,10.7600,10.9200,10.6800,10.8000,1150.00
2023-02-11,10.7800,10.9400,10.7000,10.8200,1180.00
2023-02-12,10.8000,10.9600,10.7200,10.8400,1000.00
2023-02-13,10.8200,10.9800,10.7400,10.8600,1030.00
2023-02-14,10.8400,11.0000,10.7600,10.8800,1060.00
2023-02-15,10.8600,11.0200,10.7800,10.9000,1090.00
2023-02-16,10.8800,11.0400,10.8000,10.9200,1120.00
2023-02-17,10.9000,11.0600,10.8200,10.9400,1150.00
2023-02-18,10.9200,11.0800,10.8400,10.9600,1180.00
2023-02-19,10.9400,11.1000,10.8600,10.9800,1000.00
2023-02-20,10.9600,11.1200,10.8800,11.0000,1030.00
2023-02-21,10.9800,11.1400,10.9000,11.0200,1060.00
2023-02-22,11.0000,11.1600,10.9200,11.0400,1090.00
2023-02-23,11.0200,11.1800,10.9400,11.0600,1120.00
2023-02-24,11.0400,11.2000,10.9600,11.0800,1150.00
2023-02-25,11.0600,11.2200,10.9800,11.1000,1180.00
2023-02-26,11.0800,11.2400,11.0000,11.1200,1000.00
2023-02-27,11.1000,11.2600,11.0200,11.1400,1030.00
2023-02-28,11.1200,11.2800,11.0400,11.1600,1060.00
2023-03-01,11.1400,11.3000,11.0600,11.1800,1090.00
2023-03-02,11.1600,11.3200,11.0800,11.2000,1120.00
2023-03-03,11.1800,11.3400,11.1000,11.2200,1150.00
2023-03-04,11.2000,11.3600,11.1200,11.2400,1180.00
2023-03-05,11.2200,11.3800,11.1400,11.2600,1000.00
2023-03-06,11.2400,11.4000,11.1600,11.2800,1030.00
2023-03-07,11.2600,11.4200,11.1800,11.3000,1060.00
2023-03-08,11.2800,11.4400,11.2000,11.3200,1090.00
2023-03-09,11.3000,11.4600,11.2200,11.3400,1120.00
2023-03-10,11.3200,11.4800,11.2400,11.3600,1150.00
2023-03-11,11.3400,11.5000,11.2600,11.3800,1180.00
2023-03-12,11.3600,11.5200,11.2800,11.4000,1000.00
2023-03-13,11.3800,11.5400,11.3000,11.4200,1030.00
2023-03-14,11.4000,11.5600,11.3200,11.4400,1060.00
2023-03-15,11.4200,11.5800,11.3400,11.4600,1090.00
2023-03-16,11.4400,11.6000,11.3600,11.4800,1120.00
2023-03-17,11.4600,11.6200,11.3800,11.5000,1150.00
2023-03-18,11.4800,11.6400,11.4000,11.5200,1180.00
2023-03-19,11.5000,11.6600,11.4200,11.5400,1000.00
2023-03-20,11.5200,11.6800,11.4400,11.5600,1030.00
2023-03-21,11.5400,11.7000,11.4600,11.5800,1060.00
2023-03-22,11.5600,11.7200,11.4800,11.6000,1090.00
2023-03-23,11.5800,11.7400,11.5000,11.6200,1120.00
2023-03-24,11.6000,11.7600,11.5200,11.6400,1150.00
2023-03-25,11.6200,11.7800,11.5400,11.6600,1180.00
2023-03-26,11.6400,11.8000,11.5600,11.6800,1000.00
2023-03-27,11.6600,11.8200,11.5800,11.7000,1030.00
2023-03-28,11.6800,11.8400,11.6000,11.7200,1060.00
2023-03-29,11.7000,11.8600,11.6200,11.7400,1090.00
2023-03-30,11.7200,11.8800,11.6400,11.7600,1120.00
2023-03-31,11.7400,11.9000,11.6600,11.7800,1150.00
2023-04-01,11.7600,11.9200,11.6800,11.8000,1180.00
2023-04-02,11.7800,11.9400,11.7000,11.8200,1000.00
2023-04-03,11.8000,11.9600,11.7200,11.8400,1030.00
2023-04-04,11.8200,11.9800,11.7400,11.8600,1060.00
2023-04-05,11.8400,12.0000,11.7600,11.8800,1090.00
2023-04-06,11.8600,12.0200,11.7800,11.9000,1120.00
2023-04-07,11.8800,12.0400,11.8000,11.9200,1150.00
2023-04-08,11.9000,12.0600,11.8200,11.9400,1180.00
2023-04-09,11.9200,12.0800,11.8400,11.9600,1000.00
2023-04-10,11.9400,12.1000,11.8600,11.9800,1030.00
2023-04-11,11.9600,12.1200,11.8800,12.0000,1060.00
2023-04-12,11.9800,12.1400,11.9000,12.0200,1090.00
2023-04-13,12.0000,12.1600,11.9200,12.0400,1120.00
2023-04-14,12.0200,12.1800,11.9400,12.0600,1150.00
2023-04-15,12.0400,12.2000,11.9600,12.0800,1180.00
2023-04-16,12.0600,12.2200,11.9800,12.1000,1000.00
2023-04-17,12.0800,12.2400,12.0000,12.1200,1030.00
2023-04-18,12.1000,12.2600,12.0200,12.1400,1060.00
2023-04-19,12.1200,12.2800,12.0400,12.1600,1090.00
2023-04-20,12.1400,12.3000,12.0600,12.1800,1120.00
2023-04-21,12.1600,12.3200,12.0800,12.2000,1150.00
2023-04-22,12.1800,12.3400,12.1000,12.2200,1180.00
2023-04-23,12.2000,12.3600,12.1200,12.2400,1000.00
2023-04-24,12.2200,12.3800,12.1400,12.2600,1030.00
2023-04-25,12.2400,12.4000,12.1600,12.2800,1060.00
1 trade_date open high low close volume
2 2023-01-01 9.9600 10.1200 9.8800 10.0000 1000.00
3 2023-01-02 9.9800 10.1400 9.9000 10.0200 1030.00
4 2023-01-03 10.0000 10.1600 9.9200 10.0400 1060.00
5 2023-01-04 10.0200 10.1800 9.9400 10.0600 1090.00
6 2023-01-05 10.0400 10.2000 9.9600 10.0800 1120.00
7 2023-01-06 10.0600 10.2200 9.9800 10.1000 1150.00
8 2023-01-07 10.0800 10.2400 10.0000 10.1200 1180.00
9 2023-01-08 10.1000 10.2600 10.0200 10.1400 1000.00
10 2023-01-09 10.1200 10.2800 10.0400 10.1600 1030.00
11 2023-01-10 10.1400 10.3000 10.0600 10.1800 1060.00
12 2023-01-11 10.1600 10.3200 10.0800 10.2000 1090.00
13 2023-01-12 10.1800 10.3400 10.1000 10.2200 1120.00
14 2023-01-13 10.2000 10.3600 10.1200 10.2400 1150.00
15 2023-01-14 10.2200 10.3800 10.1400 10.2600 1180.00
16 2023-01-15 10.2400 10.4000 10.1600 10.2800 1000.00
17 2023-01-16 10.2600 10.4200 10.1800 10.3000 1030.00
18 2023-01-17 10.2800 10.4400 10.2000 10.3200 1060.00
19 2023-01-18 10.3000 10.4600 10.2200 10.3400 1090.00
20 2023-01-19 10.3200 10.4800 10.2400 10.3600 1120.00
21 2023-01-20 10.3400 10.5000 10.2600 10.3800 1150.00
22 2023-01-21 10.3600 10.5200 10.2800 10.4000 1180.00
23 2023-01-22 10.3800 10.5400 10.3000 10.4200 1000.00
24 2023-01-23 10.4000 10.5600 10.3200 10.4400 1030.00
25 2023-01-24 10.4200 10.5800 10.3400 10.4600 1060.00
26 2023-01-25 10.4400 10.6000 10.3600 10.4800 1090.00
27 2023-01-26 10.4600 10.6200 10.3800 10.5000 1120.00
28 2023-01-27 10.4800 10.6400 10.4000 10.5200 1150.00
29 2023-01-28 10.5000 10.6600 10.4200 10.5400 1180.00
30 2023-01-29 10.5200 10.6800 10.4400 10.5600 1000.00
31 2023-01-30 10.5400 10.7000 10.4600 10.5800 1030.00
32 2023-01-31 10.5600 10.7200 10.4800 10.6000 1060.00
33 2023-02-01 10.5800 10.7400 10.5000 10.6200 1090.00
34 2023-02-02 10.6000 10.7600 10.5200 10.6400 1120.00
35 2023-02-03 10.6200 10.7800 10.5400 10.6600 1150.00
36 2023-02-04 10.6400 10.8000 10.5600 10.6800 1180.00
37 2023-02-05 10.6600 10.8200 10.5800 10.7000 1000.00
38 2023-02-06 10.6800 10.8400 10.6000 10.7200 1030.00
39 2023-02-07 10.7000 10.8600 10.6200 10.7400 1060.00
40 2023-02-08 10.7200 10.8800 10.6400 10.7600 1090.00
41 2023-02-09 10.7400 10.9000 10.6600 10.7800 1120.00
42 2023-02-10 10.7600 10.9200 10.6800 10.8000 1150.00
43 2023-02-11 10.7800 10.9400 10.7000 10.8200 1180.00
44 2023-02-12 10.8000 10.9600 10.7200 10.8400 1000.00
45 2023-02-13 10.8200 10.9800 10.7400 10.8600 1030.00
46 2023-02-14 10.8400 11.0000 10.7600 10.8800 1060.00
47 2023-02-15 10.8600 11.0200 10.7800 10.9000 1090.00
48 2023-02-16 10.8800 11.0400 10.8000 10.9200 1120.00
49 2023-02-17 10.9000 11.0600 10.8200 10.9400 1150.00
50 2023-02-18 10.9200 11.0800 10.8400 10.9600 1180.00
51 2023-02-19 10.9400 11.1000 10.8600 10.9800 1000.00
52 2023-02-20 10.9600 11.1200 10.8800 11.0000 1030.00
53 2023-02-21 10.9800 11.1400 10.9000 11.0200 1060.00
54 2023-02-22 11.0000 11.1600 10.9200 11.0400 1090.00
55 2023-02-23 11.0200 11.1800 10.9400 11.0600 1120.00
56 2023-02-24 11.0400 11.2000 10.9600 11.0800 1150.00
57 2023-02-25 11.0600 11.2200 10.9800 11.1000 1180.00
58 2023-02-26 11.0800 11.2400 11.0000 11.1200 1000.00
59 2023-02-27 11.1000 11.2600 11.0200 11.1400 1030.00
60 2023-02-28 11.1200 11.2800 11.0400 11.1600 1060.00
61 2023-03-01 11.1400 11.3000 11.0600 11.1800 1090.00
62 2023-03-02 11.1600 11.3200 11.0800 11.2000 1120.00
63 2023-03-03 11.1800 11.3400 11.1000 11.2200 1150.00
64 2023-03-04 11.2000 11.3600 11.1200 11.2400 1180.00
65 2023-03-05 11.2200 11.3800 11.1400 11.2600 1000.00
66 2023-03-06 11.2400 11.4000 11.1600 11.2800 1030.00
67 2023-03-07 11.2600 11.4200 11.1800 11.3000 1060.00
68 2023-03-08 11.2800 11.4400 11.2000 11.3200 1090.00
69 2023-03-09 11.3000 11.4600 11.2200 11.3400 1120.00
70 2023-03-10 11.3200 11.4800 11.2400 11.3600 1150.00
71 2023-03-11 11.3400 11.5000 11.2600 11.3800 1180.00
72 2023-03-12 11.3600 11.5200 11.2800 11.4000 1000.00
73 2023-03-13 11.3800 11.5400 11.3000 11.4200 1030.00
74 2023-03-14 11.4000 11.5600 11.3200 11.4400 1060.00
75 2023-03-15 11.4200 11.5800 11.3400 11.4600 1090.00
76 2023-03-16 11.4400 11.6000 11.3600 11.4800 1120.00
77 2023-03-17 11.4600 11.6200 11.3800 11.5000 1150.00
78 2023-03-18 11.4800 11.6400 11.4000 11.5200 1180.00
79 2023-03-19 11.5000 11.6600 11.4200 11.5400 1000.00
80 2023-03-20 11.5200 11.6800 11.4400 11.5600 1030.00
81 2023-03-21 11.5400 11.7000 11.4600 11.5800 1060.00
82 2023-03-22 11.5600 11.7200 11.4800 11.6000 1090.00
83 2023-03-23 11.5800 11.7400 11.5000 11.6200 1120.00
84 2023-03-24 11.6000 11.7600 11.5200 11.6400 1150.00
85 2023-03-25 11.6200 11.7800 11.5400 11.6600 1180.00
86 2023-03-26 11.6400 11.8000 11.5600 11.6800 1000.00
87 2023-03-27 11.6600 11.8200 11.5800 11.7000 1030.00
88 2023-03-28 11.6800 11.8400 11.6000 11.7200 1060.00
89 2023-03-29 11.7000 11.8600 11.6200 11.7400 1090.00
90 2023-03-30 11.7200 11.8800 11.6400 11.7600 1120.00
91 2023-03-31 11.7400 11.9000 11.6600 11.7800 1150.00
92 2023-04-01 11.7600 11.9200 11.6800 11.8000 1180.00
93 2023-04-02 11.7800 11.9400 11.7000 11.8200 1000.00
94 2023-04-03 11.8000 11.9600 11.7200 11.8400 1030.00
95 2023-04-04 11.8200 11.9800 11.7400 11.8600 1060.00
96 2023-04-05 11.8400 12.0000 11.7600 11.8800 1090.00
97 2023-04-06 11.8600 12.0200 11.7800 11.9000 1120.00
98 2023-04-07 11.8800 12.0400 11.8000 11.9200 1150.00
99 2023-04-08 11.9000 12.0600 11.8200 11.9400 1180.00
100 2023-04-09 11.9200 12.0800 11.8400 11.9600 1000.00
101 2023-04-10 11.9400 12.1000 11.8600 11.9800 1030.00
102 2023-04-11 11.9600 12.1200 11.8800 12.0000 1060.00
103 2023-04-12 11.9800 12.1400 11.9000 12.0200 1090.00
104 2023-04-13 12.0000 12.1600 11.9200 12.0400 1120.00
105 2023-04-14 12.0200 12.1800 11.9400 12.0600 1150.00
106 2023-04-15 12.0400 12.2000 11.9600 12.0800 1180.00
107 2023-04-16 12.0600 12.2200 11.9800 12.1000 1000.00
108 2023-04-17 12.0800 12.2400 12.0000 12.1200 1030.00
109 2023-04-18 12.1000 12.2600 12.0200 12.1400 1060.00
110 2023-04-19 12.1200 12.2800 12.0400 12.1600 1090.00
111 2023-04-20 12.1400 12.3000 12.0600 12.1800 1120.00
112 2023-04-21 12.1600 12.3200 12.0800 12.2000 1150.00
113 2023-04-22 12.1800 12.3400 12.1000 12.2200 1180.00
114 2023-04-23 12.2000 12.3600 12.1200 12.2400 1000.00
115 2023-04-24 12.2200 12.3800 12.1400 12.2600 1030.00
116 2023-04-25 12.2400 12.4000 12.1600 12.2800 1060.00
@@ -0,0 +1,116 @@
trade_date,open,high,low,close,volume
2023-01-01,19.9600,20.1200,19.8800,20.0000,1600.00
2023-01-02,19.9900,20.1500,19.9100,20.0300,1630.00
2023-01-03,20.0200,20.1800,19.9400,20.0600,1660.00
2023-01-04,20.0500,20.2100,19.9700,20.0900,1690.00
2023-01-05,20.0800,20.2400,20.0000,20.1200,1720.00
2023-01-06,20.1100,20.2700,20.0300,20.1500,1750.00
2023-01-07,20.1400,20.3000,20.0600,20.1800,1780.00
2023-01-08,20.1700,20.3300,20.0900,20.2100,1600.00
2023-01-09,20.2000,20.3600,20.1200,20.2400,1630.00
2023-01-10,20.2300,20.3900,20.1500,20.2700,1660.00
2023-01-11,20.2600,20.4200,20.1800,20.3000,1690.00
2023-01-12,20.2900,20.4500,20.2100,20.3300,1720.00
2023-01-13,20.3200,20.4800,20.2400,20.3600,1750.00
2023-01-14,20.3500,20.5100,20.2700,20.3900,1780.00
2023-01-15,20.3800,20.5400,20.3000,20.4200,1600.00
2023-01-16,20.4100,20.5700,20.3300,20.4500,1630.00
2023-01-17,20.4400,20.6000,20.3600,20.4800,1660.00
2023-01-18,20.4700,20.6300,20.3900,20.5100,1690.00
2023-01-19,20.5000,20.6600,20.4200,20.5400,1720.00
2023-01-20,20.5300,20.6900,20.4500,20.5700,1750.00
2023-01-21,20.5600,20.7200,20.4800,20.6000,1780.00
2023-01-22,20.5900,20.7500,20.5100,20.6300,1600.00
2023-01-23,20.6200,20.7800,20.5400,20.6600,1630.00
2023-01-24,20.6500,20.8100,20.5700,20.6900,1660.00
2023-01-25,20.6800,20.8400,20.6000,20.7200,1690.00
2023-01-26,20.7100,20.8700,20.6300,20.7500,1720.00
2023-01-27,20.7400,20.9000,20.6600,20.7800,1750.00
2023-01-28,20.7700,20.9300,20.6900,20.8100,1780.00
2023-01-29,20.8000,20.9600,20.7200,20.8400,1600.00
2023-01-30,20.8300,20.9900,20.7500,20.8700,1630.00
2023-01-31,20.8600,21.0200,20.7800,20.9000,1660.00
2023-02-01,20.8900,21.0500,20.8100,20.9300,1690.00
2023-02-02,20.9200,21.0800,20.8400,20.9600,1720.00
2023-02-03,20.9500,21.1100,20.8700,20.9900,1750.00
2023-02-04,20.9800,21.1400,20.9000,21.0200,1780.00
2023-02-05,21.0100,21.1700,20.9300,21.0500,1600.00
2023-02-06,21.0400,21.2000,20.9600,21.0800,1630.00
2023-02-07,21.0700,21.2300,20.9900,21.1100,1660.00
2023-02-08,21.1000,21.2600,21.0200,21.1400,1690.00
2023-02-09,21.1300,21.2900,21.0500,21.1700,1720.00
2023-02-10,21.1600,21.3200,21.0800,21.2000,1750.00
2023-02-11,21.1900,21.3500,21.1100,21.2300,1780.00
2023-02-12,21.2200,21.3800,21.1400,21.2600,1600.00
2023-02-13,21.2500,21.4100,21.1700,21.2900,1630.00
2023-02-14,21.2800,21.4400,21.2000,21.3200,1660.00
2023-02-15,21.3100,21.4700,21.2300,21.3500,1690.00
2023-02-16,21.3400,21.5000,21.2600,21.3800,1720.00
2023-02-17,21.3700,21.5300,21.2900,21.4100,1750.00
2023-02-18,21.4000,21.5600,21.3200,21.4400,1780.00
2023-02-19,21.4300,21.5900,21.3500,21.4700,1600.00
2023-02-20,21.4600,21.6200,21.3800,21.5000,1630.00
2023-02-21,21.4900,21.6500,21.4100,21.5300,1660.00
2023-02-22,21.5200,21.6800,21.4400,21.5600,1690.00
2023-02-23,21.5500,21.7100,21.4700,21.5900,1720.00
2023-02-24,21.5800,21.7400,21.5000,21.6200,1750.00
2023-02-25,21.6100,21.7700,21.5300,21.6500,1780.00
2023-02-26,21.6400,21.8000,21.5600,21.6800,1600.00
2023-02-27,21.6700,21.8300,21.5900,21.7100,1630.00
2023-02-28,21.7000,21.8600,21.6200,21.7400,1660.00
2023-03-01,21.7300,21.8900,21.6500,21.7700,1690.00
2023-03-02,21.7600,21.9200,21.6800,21.8000,1720.00
2023-03-03,21.7900,21.9500,21.7100,21.8300,1750.00
2023-03-04,21.8200,21.9800,21.7400,21.8600,1780.00
2023-03-05,21.8500,22.0100,21.7700,21.8900,1600.00
2023-03-06,21.8800,22.0400,21.8000,21.9200,1630.00
2023-03-07,21.9100,22.0700,21.8300,21.9500,1660.00
2023-03-08,21.9400,22.1000,21.8600,21.9800,1690.00
2023-03-09,21.9700,22.1300,21.8900,22.0100,1720.00
2023-03-10,22.0000,22.1600,21.9200,22.0400,1750.00
2023-03-11,22.0300,22.1900,21.9500,22.0700,1780.00
2023-03-12,22.0600,22.2200,21.9800,22.1000,1600.00
2023-03-13,22.0900,22.2500,22.0100,22.1300,1630.00
2023-03-14,22.1200,22.2800,22.0400,22.1600,1660.00
2023-03-15,22.1500,22.3100,22.0700,22.1900,1690.00
2023-03-16,22.1800,22.3400,22.1000,22.2200,1720.00
2023-03-17,22.2100,22.3700,22.1300,22.2500,1750.00
2023-03-18,22.2400,22.4000,22.1600,22.2800,1780.00
2023-03-19,22.2700,22.4300,22.1900,22.3100,1600.00
2023-03-20,22.3000,22.4600,22.2200,22.3400,1630.00
2023-03-21,22.3300,22.4900,22.2500,22.3700,1660.00
2023-03-22,22.3600,22.5200,22.2800,22.4000,1690.00
2023-03-23,22.3900,22.5500,22.3100,22.4300,1720.00
2023-03-24,22.4200,22.5800,22.3400,22.4600,1750.00
2023-03-25,22.4500,22.6100,22.3700,22.4900,1780.00
2023-03-26,22.4800,22.6400,22.4000,22.5200,1600.00
2023-03-27,22.5100,22.6700,22.4300,22.5500,1630.00
2023-03-28,22.5400,22.7000,22.4600,22.5800,1660.00
2023-03-29,22.5700,22.7300,22.4900,22.6100,1690.00
2023-03-30,22.6000,22.7600,22.5200,22.6400,1720.00
2023-03-31,22.6300,22.7900,22.5500,22.6700,1750.00
2023-04-01,22.6600,22.8200,22.5800,22.7000,1780.00
2023-04-02,22.6900,22.8500,22.6100,22.7300,1600.00
2023-04-03,22.7200,22.8800,22.6400,22.7600,1630.00
2023-04-04,22.7500,22.9100,22.6700,22.7900,1660.00
2023-04-05,22.7800,22.9400,22.7000,22.8200,1690.00
2023-04-06,22.8100,22.9700,22.7300,22.8500,1720.00
2023-04-07,22.8400,23.0000,22.7600,22.8800,1750.00
2023-04-08,22.8700,23.0300,22.7900,22.9100,1780.00
2023-04-09,22.9000,23.0600,22.8200,22.9400,1600.00
2023-04-10,22.9300,23.0900,22.8500,22.9700,1630.00
2023-04-11,22.9600,23.1200,22.8800,23.0000,1660.00
2023-04-12,22.9900,23.1500,22.9100,23.0300,1690.00
2023-04-13,23.0200,23.1800,22.9400,23.0600,1720.00
2023-04-14,23.0500,23.2100,22.9700,23.0900,1750.00
2023-04-15,23.0800,23.2400,23.0000,23.1200,1780.00
2023-04-16,23.1100,23.2700,23.0300,23.1500,1600.00
2023-04-17,23.1400,23.3000,23.0600,23.1800,1630.00
2023-04-18,23.1700,23.3300,23.0900,23.2100,1660.00
2023-04-19,23.2000,23.3600,23.1200,23.2400,1690.00
2023-04-20,23.2300,23.3900,23.1500,23.2700,1720.00
2023-04-21,23.2600,23.4200,23.1800,23.3000,1750.00
2023-04-22,23.2900,23.4500,23.2100,23.3300,1780.00
2023-04-23,23.3200,23.4800,23.2400,23.3600,1600.00
2023-04-24,23.3500,23.5100,23.2700,23.3900,1630.00
2023-04-25,23.3800,23.5400,23.3000,23.4200,1660.00
1 trade_date open high low close volume
2 2023-01-01 19.9600 20.1200 19.8800 20.0000 1600.00
3 2023-01-02 19.9900 20.1500 19.9100 20.0300 1630.00
4 2023-01-03 20.0200 20.1800 19.9400 20.0600 1660.00
5 2023-01-04 20.0500 20.2100 19.9700 20.0900 1690.00
6 2023-01-05 20.0800 20.2400 20.0000 20.1200 1720.00
7 2023-01-06 20.1100 20.2700 20.0300 20.1500 1750.00
8 2023-01-07 20.1400 20.3000 20.0600 20.1800 1780.00
9 2023-01-08 20.1700 20.3300 20.0900 20.2100 1600.00
10 2023-01-09 20.2000 20.3600 20.1200 20.2400 1630.00
11 2023-01-10 20.2300 20.3900 20.1500 20.2700 1660.00
12 2023-01-11 20.2600 20.4200 20.1800 20.3000 1690.00
13 2023-01-12 20.2900 20.4500 20.2100 20.3300 1720.00
14 2023-01-13 20.3200 20.4800 20.2400 20.3600 1750.00
15 2023-01-14 20.3500 20.5100 20.2700 20.3900 1780.00
16 2023-01-15 20.3800 20.5400 20.3000 20.4200 1600.00
17 2023-01-16 20.4100 20.5700 20.3300 20.4500 1630.00
18 2023-01-17 20.4400 20.6000 20.3600 20.4800 1660.00
19 2023-01-18 20.4700 20.6300 20.3900 20.5100 1690.00
20 2023-01-19 20.5000 20.6600 20.4200 20.5400 1720.00
21 2023-01-20 20.5300 20.6900 20.4500 20.5700 1750.00
22 2023-01-21 20.5600 20.7200 20.4800 20.6000 1780.00
23 2023-01-22 20.5900 20.7500 20.5100 20.6300 1600.00
24 2023-01-23 20.6200 20.7800 20.5400 20.6600 1630.00
25 2023-01-24 20.6500 20.8100 20.5700 20.6900 1660.00
26 2023-01-25 20.6800 20.8400 20.6000 20.7200 1690.00
27 2023-01-26 20.7100 20.8700 20.6300 20.7500 1720.00
28 2023-01-27 20.7400 20.9000 20.6600 20.7800 1750.00
29 2023-01-28 20.7700 20.9300 20.6900 20.8100 1780.00
30 2023-01-29 20.8000 20.9600 20.7200 20.8400 1600.00
31 2023-01-30 20.8300 20.9900 20.7500 20.8700 1630.00
32 2023-01-31 20.8600 21.0200 20.7800 20.9000 1660.00
33 2023-02-01 20.8900 21.0500 20.8100 20.9300 1690.00
34 2023-02-02 20.9200 21.0800 20.8400 20.9600 1720.00
35 2023-02-03 20.9500 21.1100 20.8700 20.9900 1750.00
36 2023-02-04 20.9800 21.1400 20.9000 21.0200 1780.00
37 2023-02-05 21.0100 21.1700 20.9300 21.0500 1600.00
38 2023-02-06 21.0400 21.2000 20.9600 21.0800 1630.00
39 2023-02-07 21.0700 21.2300 20.9900 21.1100 1660.00
40 2023-02-08 21.1000 21.2600 21.0200 21.1400 1690.00
41 2023-02-09 21.1300 21.2900 21.0500 21.1700 1720.00
42 2023-02-10 21.1600 21.3200 21.0800 21.2000 1750.00
43 2023-02-11 21.1900 21.3500 21.1100 21.2300 1780.00
44 2023-02-12 21.2200 21.3800 21.1400 21.2600 1600.00
45 2023-02-13 21.2500 21.4100 21.1700 21.2900 1630.00
46 2023-02-14 21.2800 21.4400 21.2000 21.3200 1660.00
47 2023-02-15 21.3100 21.4700 21.2300 21.3500 1690.00
48 2023-02-16 21.3400 21.5000 21.2600 21.3800 1720.00
49 2023-02-17 21.3700 21.5300 21.2900 21.4100 1750.00
50 2023-02-18 21.4000 21.5600 21.3200 21.4400 1780.00
51 2023-02-19 21.4300 21.5900 21.3500 21.4700 1600.00
52 2023-02-20 21.4600 21.6200 21.3800 21.5000 1630.00
53 2023-02-21 21.4900 21.6500 21.4100 21.5300 1660.00
54 2023-02-22 21.5200 21.6800 21.4400 21.5600 1690.00
55 2023-02-23 21.5500 21.7100 21.4700 21.5900 1720.00
56 2023-02-24 21.5800 21.7400 21.5000 21.6200 1750.00
57 2023-02-25 21.6100 21.7700 21.5300 21.6500 1780.00
58 2023-02-26 21.6400 21.8000 21.5600 21.6800 1600.00
59 2023-02-27 21.6700 21.8300 21.5900 21.7100 1630.00
60 2023-02-28 21.7000 21.8600 21.6200 21.7400 1660.00
61 2023-03-01 21.7300 21.8900 21.6500 21.7700 1690.00
62 2023-03-02 21.7600 21.9200 21.6800 21.8000 1720.00
63 2023-03-03 21.7900 21.9500 21.7100 21.8300 1750.00
64 2023-03-04 21.8200 21.9800 21.7400 21.8600 1780.00
65 2023-03-05 21.8500 22.0100 21.7700 21.8900 1600.00
66 2023-03-06 21.8800 22.0400 21.8000 21.9200 1630.00
67 2023-03-07 21.9100 22.0700 21.8300 21.9500 1660.00
68 2023-03-08 21.9400 22.1000 21.8600 21.9800 1690.00
69 2023-03-09 21.9700 22.1300 21.8900 22.0100 1720.00
70 2023-03-10 22.0000 22.1600 21.9200 22.0400 1750.00
71 2023-03-11 22.0300 22.1900 21.9500 22.0700 1780.00
72 2023-03-12 22.0600 22.2200 21.9800 22.1000 1600.00
73 2023-03-13 22.0900 22.2500 22.0100 22.1300 1630.00
74 2023-03-14 22.1200 22.2800 22.0400 22.1600 1660.00
75 2023-03-15 22.1500 22.3100 22.0700 22.1900 1690.00
76 2023-03-16 22.1800 22.3400 22.1000 22.2200 1720.00
77 2023-03-17 22.2100 22.3700 22.1300 22.2500 1750.00
78 2023-03-18 22.2400 22.4000 22.1600 22.2800 1780.00
79 2023-03-19 22.2700 22.4300 22.1900 22.3100 1600.00
80 2023-03-20 22.3000 22.4600 22.2200 22.3400 1630.00
81 2023-03-21 22.3300 22.4900 22.2500 22.3700 1660.00
82 2023-03-22 22.3600 22.5200 22.2800 22.4000 1690.00
83 2023-03-23 22.3900 22.5500 22.3100 22.4300 1720.00
84 2023-03-24 22.4200 22.5800 22.3400 22.4600 1750.00
85 2023-03-25 22.4500 22.6100 22.3700 22.4900 1780.00
86 2023-03-26 22.4800 22.6400 22.4000 22.5200 1600.00
87 2023-03-27 22.5100 22.6700 22.4300 22.5500 1630.00
88 2023-03-28 22.5400 22.7000 22.4600 22.5800 1660.00
89 2023-03-29 22.5700 22.7300 22.4900 22.6100 1690.00
90 2023-03-30 22.6000 22.7600 22.5200 22.6400 1720.00
91 2023-03-31 22.6300 22.7900 22.5500 22.6700 1750.00
92 2023-04-01 22.6600 22.8200 22.5800 22.7000 1780.00
93 2023-04-02 22.6900 22.8500 22.6100 22.7300 1600.00
94 2023-04-03 22.7200 22.8800 22.6400 22.7600 1630.00
95 2023-04-04 22.7500 22.9100 22.6700 22.7900 1660.00
96 2023-04-05 22.7800 22.9400 22.7000 22.8200 1690.00
97 2023-04-06 22.8100 22.9700 22.7300 22.8500 1720.00
98 2023-04-07 22.8400 23.0000 22.7600 22.8800 1750.00
99 2023-04-08 22.8700 23.0300 22.7900 22.9100 1780.00
100 2023-04-09 22.9000 23.0600 22.8200 22.9400 1600.00
101 2023-04-10 22.9300 23.0900 22.8500 22.9700 1630.00
102 2023-04-11 22.9600 23.1200 22.8800 23.0000 1660.00
103 2023-04-12 22.9900 23.1500 22.9100 23.0300 1690.00
104 2023-04-13 23.0200 23.1800 22.9400 23.0600 1720.00
105 2023-04-14 23.0500 23.2100 22.9700 23.0900 1750.00
106 2023-04-15 23.0800 23.2400 23.0000 23.1200 1780.00
107 2023-04-16 23.1100 23.2700 23.0300 23.1500 1600.00
108 2023-04-17 23.1400 23.3000 23.0600 23.1800 1630.00
109 2023-04-18 23.1700 23.3300 23.0900 23.2100 1660.00
110 2023-04-19 23.2000 23.3600 23.1200 23.2400 1690.00
111 2023-04-20 23.2300 23.3900 23.1500 23.2700 1720.00
112 2023-04-21 23.2600 23.4200 23.1800 23.3000 1750.00
113 2023-04-22 23.2900 23.4500 23.2100 23.3300 1780.00
114 2023-04-23 23.3200 23.4800 23.2400 23.3600 1600.00
115 2023-04-24 23.3500 23.5100 23.2700 23.3900 1630.00
116 2023-04-25 23.3800 23.5400 23.3000 23.4200 1660.00
@@ -0,0 +1,41 @@
"""Offline golden checks for fixed, non-legacy selection fixtures."""
import csv
import json
from datetime import date
from pathlib import Path
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
from zhixing_server.modules.selection.domain.zhixing_b1 import ZhixingB1Strategy
FIXTURE_ROOT = Path(__file__).parents[1] / "fixtures" / "selection" / "zhixing_b1"
def _read_history(path: Path, ts_code: str) -> StockHistory:
with path.open(newline="") as file:
bars = tuple(
SelectionBar(
trade_date=date.fromisoformat(row["trade_date"]),
open=float(row["open"]),
high=float(row["high"]),
low=float(row["low"]),
close=float(row["close"]),
volume=float(row["volume"]),
)
for row in csv.DictReader(file)
)
return StockHistory(ts_code=ts_code, name="fixture", bars=bars)
def test_fixed_ordinary_and_wide_limit_goldens_are_reproducible() -> None:
with (FIXTURE_ROOT / "golden.json").open() as file:
golden = json.load(file)
strategy = ZhixingB1Strategy()
for key in ("ordinary", "wide_limit"):
expected = golden[key]
history = _read_history(FIXTURE_ROOT / expected["file"], expected["ts_code"])
target = date.fromisoformat(expected["target_trade_date"])
result = strategy.evaluate(history, target)
assert result.status == expected["status"]
assert [signal.category.value for signal in result.signals] == expected["categories"]
@@ -0,0 +1,31 @@
"""Application-level state and port mapping tests."""
from datetime import date
from zhixing_server.modules.selection.application.evaluate import EvaluateZhixingB1
from zhixing_server.modules.selection.domain.models import StockHistory
from zhixing_server.modules.selection.domain.ports import MarketDataReaderError
TARGET = date(2024, 1, 2)
class EmptyReader:
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
return StockHistory(ts_code=ts_code, name="", bars=())
class FailingReader:
def load_history(self, ts_code: str, target_trade_date: date) -> StockHistory:
raise MarketDataReaderError(f"database unavailable for {ts_code}")
def test_evaluate_maps_reader_error_to_data_error() -> None:
result = EvaluateZhixingB1(FailingReader()).execute("000001.SZ", TARGET)
assert result.status == "data_error"
assert result.signals == ()
assert "000001.SZ" in (result.reason or "")
def test_evaluate_distinguishes_missing_target_from_reader_error() -> None:
result = EvaluateZhixingB1(EmptyReader()).execute("000001.SZ", TARGET)
assert result.status == "missing_target_bar"
@@ -0,0 +1,69 @@
"""Boundary tests for TDX-style selection indicators."""
import numpy as np
import pandas as pd
import pytest
from zhixing_server.modules.selection.domain.indicators import (
BARSLAST,
COUNT,
CROSS,
EVERY,
HHVBARS,
MA,
REF,
compute_amplitude_params,
compute_kdj,
compute_rsi,
)
def test_rolling_primitives_use_trading_rows_and_keep_every_warmup() -> None:
values = pd.Series([1.0, 2.0, 3.0, 2.0])
assert MA(values, 3).tolist() == [1.0, 1.5, 2.0, 7 / 3]
assert REF(values, 1).isna().iloc[0]
assert EVERY(pd.Series([True, True, True]), 3).tolist() == [False, False, True]
assert COUNT(pd.Series([True, False, True]), 2).tolist() == [1.0, 1.0, 1.0]
def test_hhvbars_and_barslast_are_stable_for_ties_and_missing_prefix() -> None:
values = pd.Series([1.0, 3.0, 3.0, 2.0])
assert HHVBARS(values, 3).tolist() == [0.0, 0.0, 0.0, 1.0]
bars_last = BARSLAST(pd.Series([False, True, False, True]))
assert np.isnan(bars_last.iloc[0])
assert bars_last.iloc[1:].tolist() == [0.0, 1.0, 0.0]
def test_cross_does_not_match_without_a_previous_complete_row() -> None:
assert CROSS(pd.Series([1.0, 3.0, 2.0]), pd.Series([2.0, 2.0, 2.0])).tolist() == [
False,
True,
False,
]
def test_zero_range_and_zero_rsi_denominator_do_not_create_finite_signals() -> None:
frame = pd.DataFrame(
{
"low": [10.0, 10.0, 10.0],
"high": [10.0, 10.0, 10.0],
"close": [10.0, 10.0, 10.0],
}
)
kdj = compute_kdj(frame, 3)
rsi = compute_rsi(frame["close"], 3)
assert kdj["J"].isna().all()
assert rsi.isna().all()
def test_amplitude_parameters_cover_wide_prefix_and_historical_wide_move() -> None:
close = pd.Series([10.0, 10.0, 11.6, 11.0])
assert compute_amplitude_params("688001", close) == (8.0, 0.9)
assert compute_amplitude_params("000001", close) == (8.0, 0.9)
assert compute_amplitude_params("000001", pd.Series([10.0, 10.1])) == (5.0, 1.0)
def test_invalid_indicator_windows_fail_loudly() -> None:
with pytest.raises(ValueError):
MA(pd.Series([1.0]), 0)
@@ -0,0 +1,84 @@
"""PostgreSQL reader contract tests using a fake connection."""
from datetime import date
from typing import cast
import psycopg
import pytest
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader,
)
class FakeConnection:
def __init__(self, rows: list[tuple[object, ...]]) -> None:
self.rows = rows
self.query: str | None = None
self.parameters: tuple[object, ...] | None = None
def __enter__(self) -> "FakeConnection":
return self
def __exit__(self, *args: object) -> None:
return None
def execute(self, query: str, parameters: tuple[object, ...]) -> "FakeResult":
self.query = query
self.parameters = parameters
return FakeResult(self.rows)
class FakeResult:
def __init__(self, rows: list[tuple[object, ...]]) -> None:
self.rows = rows
def fetchall(self) -> list[tuple[object, ...]]:
return self.rows
def test_reader_parameterizes_target_and_maps_left_join(monkeypatch: pytest.MonkeyPatch) -> None:
connection = FakeConnection(
[
(
"000001.SZ",
"平安银行",
date(2024, 1, 2),
"10",
"11",
"9",
"10.5",
"1000",
None,
None,
),
(
"000001.SZ",
"平安银行",
date(2024, 1, 3),
"10.5",
"11",
"10",
"10.8",
"1200",
"1.2",
"100000",
),
]
)
def connect(database_url: str) -> FakeConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
history = PostgresMarketDataReader("postgresql://test").load_history(
"000001.SZ", date(2024, 1, 3)
)
assert [bar.trade_date for bar in history.bars] == [date(2024, 1, 2), date(2024, 1, 3)]
assert history.daily_basic[date(2024, 1, 2)].turnover_rate is None
assert history.daily_basic[date(2024, 1, 3)].total_mv == 100000.0
assert connection.parameters == ("000001.SZ", date(2024, 1, 3))
assert "source_adj = 'qfq'" in cast(str, connection.query)
assert "trade_date <= %s" in cast(str, connection.query)
@@ -0,0 +1,94 @@
"""Behavior tests for explicit-date Zhixing B1 evaluation."""
from datetime import date, timedelta
import pandas as pd
import pytest
from zhixing_server.modules.selection.domain import zhixing_b1
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
from zhixing_server.modules.selection.domain.zhixing_b1 import (
MINIMUM_HISTORY,
ZHIXING_B1_SIGNAL_ORDER,
ZhixingB1Strategy,
compute_signal_masks,
prepare_zhixing_b1_indicators,
)
def make_history(count: int = MINIMUM_HISTORY, code: str = "000001.SZ") -> StockHistory:
bars = tuple(
SelectionBar(
trade_date=date(2020, 1, 1) + timedelta(days=index),
open=10.0 + index * 0.02,
high=10.2 + index * 0.02,
low=9.9 + index * 0.02,
close=10.1 + index * 0.02,
volume=1000.0 + (index % 7) * 30,
)
for index in range(count)
)
return StockHistory(ts_code=code, name="测试股票", bars=bars)
def test_strategy_has_seven_stable_categories_and_prepared_masks() -> None:
history = make_history()
frame = pd.DataFrame(
{
"open": [bar.open for bar in history.bars],
"high": [bar.high for bar in history.bars],
"low": [bar.low for bar in history.bars],
"close": [bar.close for bar in history.bars],
"volume": [bar.volume for bar in history.bars],
}
)
prepared = prepare_zhixing_b1_indicators(frame, history.ts_code)
masks = compute_signal_masks(prepared)
assert tuple(masks) == ZHIXING_B1_SIGNAL_ORDER
assert all(mask.dtype == bool for mask in masks.values())
assert all(len(mask) == len(history.bars) for mask in masks.values())
def test_strategy_explicit_target_ignores_future_rows() -> None:
history = make_history()
target = history.bars[-1].trade_date
future = SelectionBar(
trade_date=target + timedelta(days=1),
open=1.0,
high=100.0,
low=0.5,
close=99.0,
volume=1_000_000.0,
)
with_future = StockHistory(history.ts_code, history.name, history.bars + (future,))
strategy = ZhixingB1Strategy()
assert strategy.evaluate(with_future, target) == strategy.evaluate(history, target)
def test_strategy_returns_missing_and_warmup_states() -> None:
strategy = ZhixingB1Strategy()
history = make_history(MINIMUM_HISTORY - 1)
target = history.bars[-1].trade_date
assert strategy.evaluate(history, target).status == "insufficient_history"
assert strategy.evaluate(history, target + timedelta(days=1)).status == "missing_target_bar"
def test_strategy_keeps_all_same_day_subsignals_in_priority_order(
monkeypatch: pytest.MonkeyPatch,
) -> None:
history = make_history()
target = history.bars[-1].trade_date
def all_masks(frame: pd.DataFrame) -> dict[zhixing_b1.ZhixingB1Category, pd.Series]:
return {
category: pd.Series(True, index=frame.index) for category in ZHIXING_B1_SIGNAL_ORDER
}
monkeypatch.setattr(zhixing_b1, "compute_signal_masks", all_masks)
result = ZhixingB1Strategy().evaluate(history, target)
assert result.status == "selected"
assert tuple(signal.category for signal in result.signals) == ZHIXING_B1_SIGNAL_ORDER
assert len({signal.identity for signal in result.signals}) == 7
+18
View File
@@ -374,6 +374,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" },
]
[[package]]
name = "pandas-stubs"
version = "3.0.5.260730"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/d2/dea4a3a56b7b5f69c5fbca9f14625fcf28e1a39a657e9833d4a10bcac593/pandas_stubs-3.0.5.260730.tar.gz", hash = "sha256:f70a232c57d93a5a2c81f8a53953e10891a5374bc92652277deb325e2e4d0ff3", size = 114631, upload-time = "2026-07-30T14:31:42.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/c2/959caec5c46f484b5f8bb6def4b0cf7a45ba6acda26f12b75142d98cc5ae/pandas_stubs-3.0.5.260730-py3-none-any.whl", hash = "sha256:60e90e3e1eda6937e337e243cbe6217e151c11137cd7eddf832af537c7310bfd", size = 174807, upload-time = "2026-07-30T14:31:41.17Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -865,6 +877,8 @@ source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
@@ -875,6 +889,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "httpx2" },
{ name = "pandas-stubs" },
{ name = "pyright" },
{ name = "pytest" },
{ name = "pytest-cov" },
@@ -885,6 +900,8 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = ">=1.18.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "numpy", specifier = ">=2.4.0" },
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.2" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "sqlalchemy", specifier = ">=2.0.46" },
@@ -895,6 +912,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
{ name = "httpx2", specifier = ">=2.9.1" },
{ name = "pandas-stubs", specifier = ">=2.3.2.250926" },
{ name = "pyright", specifier = ">=1.1.411" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=7.1.0" },