feat(selection): 集成 B1 FastDTW 图形评分

This commit is contained in:
yuxuanhui
2026-08-31 16:14:16 +08:00
parent 86762c0d9a
commit 6ce291e242
51 changed files with 2917 additions and 41 deletions
@@ -0,0 +1,142 @@
"""Add stock-level versioned B1 pattern scoring results."""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0004_selection_pattern_scoring"
down_revision: str | None = "0003_market_integrity_checks"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_PATTERN_BREAKDOWN_CHECK = """
match_breakdown IS NULL OR (
jsonb_typeof(match_breakdown) = 'object'
AND CASE WHEN jsonb_typeof(match_breakdown -> 'trend_structure') = 'number'
THEN (match_breakdown ->> 'trend_structure')::numeric BETWEEN 0 AND 100
ELSE false END
AND CASE WHEN jsonb_typeof(match_breakdown -> 'kdj_state') = 'number'
THEN (match_breakdown ->> 'kdj_state')::numeric BETWEEN 0 AND 100
ELSE false END
AND CASE WHEN jsonb_typeof(match_breakdown -> 'volume_pattern') = 'number'
THEN (match_breakdown ->> 'volume_pattern')::numeric BETWEEN 0 AND 100
ELSE false END
AND CASE WHEN jsonb_typeof(match_breakdown -> 'price_shape') = 'number'
THEN (match_breakdown ->> 'price_shape')::numeric BETWEEN 0 AND 100
ELSE false END
)
"""
def upgrade() -> None:
"""Add nullable scoring data while keeping existing runs readable."""
op.add_column(
"selection_run_item",
sa.Column(
"score_status",
sa.String(32),
nullable=False,
server_default="not_executed",
),
)
op.add_column("selection_run_item", sa.Column("score_value", sa.Numeric(5, 2)))
op.add_column("selection_run_item", sa.Column("score_threshold", sa.Numeric(5, 2)))
op.add_column("selection_run_item", sa.Column("score_version", sa.String(64)))
op.add_column("selection_run_item", sa.Column("match_case_id", sa.String(32)))
op.add_column("selection_run_item", sa.Column("match_case_name", sa.String(128)))
op.add_column("selection_run_item", sa.Column("match_case_breakout_date", sa.Date()))
op.add_column(
"selection_run_item",
sa.Column("match_breakdown", postgresql.JSONB(astext_type=sa.Text())),
)
op.add_column("selection_run_item", sa.Column("score_reason", sa.Text()))
op.create_check_constraint(
"ck_selection_run_item_score_status",
"selection_run_item",
"score_status IN ('not_executed', 'matched', 'below_threshold', 'failed')",
)
op.create_check_constraint(
"ck_selection_run_item_score_value_range",
"selection_run_item",
"score_value IS NULL OR score_value BETWEEN 0 AND 100",
)
op.create_check_constraint(
"ck_selection_run_item_score_threshold_range",
"selection_run_item",
"score_threshold IS NULL OR score_threshold BETWEEN 0 AND 100",
)
op.create_check_constraint(
"ck_selection_run_item_breakdown_range",
"selection_run_item",
_PATTERN_BREAKDOWN_CHECK,
)
op.create_check_constraint(
"ck_selection_run_item_score_shape",
"selection_run_item",
"(score_status = 'not_executed' AND score_value IS NULL AND score_threshold IS NULL "
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
"AND score_reason IS NULL) "
"OR (score_status = 'failed' AND score_value IS NULL AND score_threshold IS NULL "
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
"AND score_reason IS NOT NULL) "
"OR (score_status IN ('matched', 'below_threshold') AND score_value IS NOT NULL "
"AND score_threshold IS NOT NULL AND score_version IS NOT NULL "
"AND match_case_id IS NOT NULL AND match_case_name IS NOT NULL "
"AND match_case_breakout_date IS NOT NULL AND match_breakdown IS NOT NULL "
"AND score_reason IS NULL "
"AND ((score_status = 'matched' AND score_value >= score_threshold) "
"OR (score_status = 'below_threshold' AND score_value < score_threshold)))",
)
op.create_index(
"ix_selection_run_item_score",
"selection_run_item",
["run_id", sa.text("score_value DESC"), "ts_code"],
)
def downgrade() -> None:
"""Remove only the additive stock-level scoring contract."""
op.drop_index("ix_selection_run_item_score", table_name="selection_run_item")
op.drop_constraint(
"ck_selection_run_item_score_shape",
"selection_run_item",
type_="check",
)
op.drop_constraint(
"ck_selection_run_item_score_threshold_range",
"selection_run_item",
type_="check",
)
op.drop_constraint(
"ck_selection_run_item_breakdown_range",
"selection_run_item",
type_="check",
)
op.drop_constraint(
"ck_selection_run_item_score_value_range",
"selection_run_item",
type_="check",
)
op.drop_constraint(
"ck_selection_run_item_score_status",
"selection_run_item",
type_="check",
)
for column in (
"score_reason",
"match_breakdown",
"match_case_breakout_date",
"match_case_name",
"match_case_id",
"score_version",
"score_threshold",
"score_value",
"score_status",
):
op.drop_column("selection_run_item", column)
+1
View File
@@ -7,6 +7,7 @@ requires-python = ">=3.12,<3.13"
dependencies = [
"alembic>=1.18.0",
"fastapi>=0.141.1",
"fastdtw>=0.3.4",
"numpy>=2.4.0",
"pandas>=2.3.3",
"psycopg[binary,pool]>=3.3.2",
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
market_data_advisory_lock_key: int = 7_380_521
selection_max_workers: int = Field(default=4, ge=1)
selection_batch_size: int = Field(default=200, ge=1)
selection_pattern_scoring_enabled: bool = True
model_config = SettingsConfigDict(
env_file=".env",
@@ -2,6 +2,7 @@
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
Date,
DateTime,
@@ -22,6 +23,24 @@ from sqlalchemy.dialects.postgresql import JSONB
metadata = MetaData()
_PATTERN_BREAKDOWN_CHECK = """
match_breakdown IS NULL OR (
jsonb_typeof(match_breakdown) = 'object'
AND CASE WHEN jsonb_typeof(match_breakdown -> 'trend_structure') = 'number'
THEN (match_breakdown ->> 'trend_structure')::numeric BETWEEN 0 AND 100
ELSE false END
AND CASE WHEN jsonb_typeof(match_breakdown -> 'kdj_state') = 'number'
THEN (match_breakdown ->> 'kdj_state')::numeric BETWEEN 0 AND 100
ELSE false END
AND CASE WHEN jsonb_typeof(match_breakdown -> 'volume_pattern') = 'number'
THEN (match_breakdown ->> 'volume_pattern')::numeric BETWEEN 0 AND 100
ELSE false END
AND CASE WHEN jsonb_typeof(match_breakdown -> 'price_shape') = 'number'
THEN (match_breakdown ->> 'price_shape')::numeric BETWEEN 0 AND 100
ELSE false END
)
"""
market_stock = Table(
"market_stock",
metadata,
@@ -153,8 +172,51 @@ selection_run_item = Table(
Column("status", String(32), nullable=False),
Column("signal_count", Integer, nullable=False, server_default="0"),
Column("reason", Text),
Column("score_status", String(32), nullable=False, server_default="not_executed"),
Column("score_value", Numeric(5, 2)),
Column("score_threshold", Numeric(5, 2)),
Column("score_version", String(64)),
Column("match_case_id", String(32)),
Column("match_case_name", String(128)),
Column("match_case_breakout_date", Date),
Column("match_breakdown", JSONB),
Column("score_reason", Text),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
PrimaryKeyConstraint("run_id", "ts_code"),
CheckConstraint(
"score_status IN ('not_executed', 'matched', 'below_threshold', 'failed')",
name="ck_selection_run_item_score_status",
),
CheckConstraint(
"score_value IS NULL OR score_value BETWEEN 0 AND 100",
name="ck_selection_run_item_score_value_range",
),
CheckConstraint(
"score_threshold IS NULL OR score_threshold BETWEEN 0 AND 100",
name="ck_selection_run_item_score_threshold_range",
),
CheckConstraint(
_PATTERN_BREAKDOWN_CHECK,
name="ck_selection_run_item_breakdown_range",
),
CheckConstraint(
"(score_status = 'not_executed' AND score_value IS NULL AND score_threshold IS NULL "
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
"AND score_reason IS NULL) "
"OR (score_status = 'failed' AND score_value IS NULL AND score_threshold IS NULL "
"AND score_version IS NULL AND match_case_id IS NULL AND match_case_name IS NULL "
"AND match_case_breakout_date IS NULL AND match_breakdown IS NULL "
"AND score_reason IS NOT NULL) "
"OR (score_status IN ('matched', 'below_threshold') AND score_value IS NOT NULL "
"AND score_threshold IS NOT NULL AND score_version IS NOT NULL "
"AND match_case_id IS NOT NULL AND match_case_name IS NOT NULL "
"AND match_case_breakout_date IS NOT NULL AND match_breakdown IS NOT NULL "
"AND score_reason IS NULL "
"AND ((score_status = 'matched' AND score_value >= score_threshold) "
"OR (score_status = 'below_threshold' AND score_value < score_threshold)))",
name="ck_selection_run_item_score_shape",
),
)
selection_signal = Table(
@@ -223,6 +285,12 @@ Index(
selection_run.c.target_trade_date,
)
Index("ix_selection_run_item_status", selection_run_item.c.run_id, selection_run_item.c.status)
Index(
"ix_selection_run_item_score",
selection_run_item.c.run_id,
selection_run_item.c.score_value.desc(),
selection_run_item.c.ts_code,
)
Index(
"ix_selection_signal_strategy_date",
selection_signal.c.strategy,
@@ -11,6 +11,12 @@ from datetime import date
from typing import Literal, Protocol, cast
from ..domain.models import SelectionEvaluation, StockHistory
from ..domain.pattern_scoring import (
PatternCase,
PatternCaseLibraryLoader,
PatternScore,
PatternScorer,
)
from ..domain.runs import (
BatchSelectionRunStore,
BatchSelectionUniverseReader,
@@ -54,7 +60,10 @@ class RunZhixingB1:
reader: SelectionUniverseReader,
store: SelectionRunStore,
evaluator: SelectionEvaluator | None = None,
pattern_case_loader: PatternCaseLibraryLoader | None = None,
pattern_scorer: PatternScorer | None = None,
*,
pattern_scoring_enabled: bool = False,
max_workers: int = 4,
batch_size: int = 200,
) -> None:
@@ -67,6 +76,9 @@ class RunZhixingB1:
self.reader = reader
self.store = store
self.evaluator = evaluator or EvaluateZhixingB1(reader)
self.pattern_case_loader = pattern_case_loader
self.pattern_scorer = pattern_scorer
self.pattern_scoring_enabled = pattern_scoring_enabled
self.max_workers = max_workers
self.batch_size = batch_size
@@ -106,7 +118,9 @@ class RunZhixingB1:
read_seconds = 0.0
evaluate_seconds = 0.0
persist_seconds = 0.0
scoring_seconds = 0.0
try:
pattern_cases, pattern_library_error = self._prepare_pattern_cases(prepared.run.id)
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
for batch_stocks in _chunks(stocks, self.batch_size):
read_started = time.perf_counter()
@@ -120,24 +134,39 @@ class RunZhixingB1:
)
evaluate_started = time.perf_counter()
evaluations = tuple(
executor.map(
self._evaluate_stock,
batch_stocks,
histories,
[prepared.source.target_trade_date] * len(batch_stocks),
)
)
evaluate_seconds += time.perf_counter() - evaluate_started
scoring_started = time.perf_counter()
items = tuple(
_to_item(
stock.ts_code,
stock.name,
evaluation,
)
for stock, evaluation in zip(
batch_stocks,
executor.map(
self._evaluate_stock,
batch_stocks,
histories,
[prepared.source.target_trade_date] * len(batch_stocks),
pattern_score=self._score_stock(
prepared.run.id,
stock,
history,
evaluation,
pattern_cases,
pattern_library_error,
),
)
for stock, history, evaluation in zip(
batch_stocks,
histories,
evaluations,
strict=True,
)
)
evaluate_seconds += time.perf_counter() - evaluate_started
scoring_seconds += time.perf_counter() - scoring_started
evaluated_count += len(items)
selected_stock_count += sum(item.status == "selected" for item in items)
@@ -184,7 +213,7 @@ class RunZhixingB1:
logger.info(
"selection_run_summary run_id=%s stock_count=%d history_rows=%d "
"batch_count=%d worker_count=%d read_seconds=%.3f "
"evaluate_seconds=%.3f persist_seconds=%.3f",
"evaluate_seconds=%.3f scoring_seconds=%.3f persist_seconds=%.3f",
prepared.run.id,
len(stocks),
history_rows,
@@ -192,9 +221,64 @@ class RunZhixingB1:
self.max_workers,
read_seconds,
evaluate_seconds,
scoring_seconds,
persist_seconds,
)
def _prepare_pattern_cases(
self,
run_id: str,
) -> tuple[tuple[PatternCase, ...] | None, str | None]:
"""Load the complete case library once without failing selection."""
if not self.pattern_scoring_enabled:
return None, None
if self.pattern_case_loader is None or self.pattern_scorer is None:
reason = "pattern scoring is enabled but not configured"
logger.error("selection_pattern_library_failed run_id=%s reason=%s", run_id, reason)
return None, reason
try:
return self.pattern_case_loader.load(), None
except Exception as exc: # noqa: BLE001 - scoring enrichment must not fail selection
reason = _safe_item_error(exc)
logger.warning(
"selection_pattern_library_failed run_id=%s error_type=%s reason=%s",
run_id,
exc.__class__.__name__,
reason,
)
return None, reason
def _score_stock(
self,
run_id: str,
stock: SelectionStock,
history: StockHistory | None,
evaluation: SelectionEvaluation,
cases: tuple[PatternCase, ...] | None,
library_error: str | None,
) -> PatternScore:
"""Score one selected stock once and isolate enrichment failures."""
if not self.pattern_scoring_enabled or evaluation.status != "selected":
return PatternScore()
if library_error is not None:
return PatternScore.failed(library_error)
if history is None or cases is None or self.pattern_scorer is None:
return PatternScore.failed("pattern scoring history or case library is unavailable")
try:
return self.pattern_scorer.score(history, cases)
except Exception as exc: # noqa: BLE001 - one score must not fail the selection run
reason = _safe_item_error(exc)
logger.warning(
"selection_pattern_score_failed run_id=%s ts_code=%s error_type=%s reason=%s",
run_id,
stock.ts_code,
exc.__class__.__name__,
reason,
)
return PatternScore.failed(reason)
def _load_histories(
self,
stocks: Sequence[SelectionStock],
@@ -287,7 +371,13 @@ class RunZhixingB1:
return self.store.get_latest_run(strategy, target_trade_date, query=query)
def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> SelectionRunItem:
def _to_item(
ts_code: str,
name: str,
evaluation: SelectionEvaluation,
*,
pattern_score: PatternScore | None = None,
) -> SelectionRunItem:
"""Translate a single-stock domain result into a stored item."""
return SelectionRunItem(
@@ -296,6 +386,7 @@ def _to_item(ts_code: str, name: str, evaluation: SelectionEvaluation) -> Select
status=evaluation.status,
signal_count=len(evaluation.signals),
reason=evaluation.reason,
pattern_score=pattern_score or PatternScore(),
signals=evaluation.signals,
)
@@ -0,0 +1,572 @@
"""Versioned Zhixing B1 pattern-similarity scoring.
The module deliberately keeps the algorithm and its ten case definitions in
one bounded-context-owned contract. Infrastructure supplies qfq histories;
the scorer performs no I/O and never falls back to a different DTW algorithm.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date
from math import isfinite
from numbers import Real
from typing import Literal, Protocol, cast
import numpy as np
import pandas as pd
from fastdtw import fastdtw # type: ignore[reportMissingTypeStubs]
from .models import SelectionBar, StockHistory
PATTERN_SCORING_VERSION = "zhixing_b1_pattern_fastdtw_v1"
PATTERN_LOOKBACK_DAYS = 25
PATTERN_SCORE_THRESHOLD = 60.0
PATTERN_FASTDTW_RADIUS = 1
PATTERN_WEIGHTS = {
"trend_structure": 0.10,
"kdj_state": 0.20,
"volume_pattern": 0.25,
"price_shape": 0.45,
}
PATTERN_TOLERANCES = {
"trend_ratio": 0.10,
"price_bias": 10.0,
"trend_spread": 10.0,
"j_value": 30.0,
"drawdown": 15.0,
}
PatternScoreStatus = Literal["not_executed", "matched", "below_threshold", "failed"]
@dataclass(frozen=True, slots=True)
class PatternCaseDefinition:
"""A versioned pattern template and its exclusive breakout boundary."""
id: str
name: str
ts_code: str
breakout_date: date
lookback_days: int = PATTERN_LOOKBACK_DAYS
ZHIXING_B1_PATTERN_CASES: tuple[PatternCaseDefinition, ...] = (
PatternCaseDefinition("case_001", "华纳药厂", "688799.SH", date(2025, 5, 12)),
PatternCaseDefinition("case_002", "宁波韵升", "600366.SH", date(2025, 8, 6)),
PatternCaseDefinition("case_003", "微芯生物", "688321.SH", date(2025, 6, 20)),
PatternCaseDefinition("case_004", "方正科技", "600601.SH", date(2025, 7, 23)),
PatternCaseDefinition("case_006", "国轩高科", "002074.SZ", date(2025, 8, 4)),
PatternCaseDefinition("case_007", "野马电池", "605378.SH", date(2025, 8, 1)),
PatternCaseDefinition("case_008", "光电股份", "600184.SH", date(2025, 7, 10)),
PatternCaseDefinition("case_009", "新瀚新材", "301076.SZ", date(2025, 8, 1)),
PatternCaseDefinition("case_010", "昂利康", "002940.SZ", date(2025, 7, 11)),
PatternCaseDefinition("case_011", "航天发展", "000547.SZ", date(2025, 11, 12)),
)
@dataclass(frozen=True, slots=True)
class PatternFeatures:
"""Immutable, finite-or-null features used by the matcher."""
trend_structure: Mapping[str, float | bool | None]
kdj_state: Mapping[str, float | bool | str | None]
volume_pattern: Mapping[str, float | bool | str | int | None]
price_shape: Mapping[str, float | str | int | tuple[float, ...] | None]
@dataclass(frozen=True, slots=True)
class PatternCase:
"""One complete case history with precomputed immutable features."""
definition: PatternCaseDefinition
history: StockHistory
features: PatternFeatures
@dataclass(frozen=True, slots=True)
class PatternScoreBreakdown:
"""Finite 0-100 scores for the four versioned pattern dimensions."""
trend_structure: float
kdj_state: float
volume_pattern: float
price_shape: float
def __post_init__(self) -> None:
"""Reject non-finite or out-of-range values before persistence."""
for name in ("trend_structure", "kdj_state", "volume_pattern", "price_shape"):
_validate_score(getattr(self, name), name)
def as_dict(self) -> dict[str, float]:
"""Return the JSONB/HTTP field names without exposing dataclass internals."""
return {
"trend_structure": self.trend_structure,
"kdj_state": self.kdj_state,
"volume_pattern": self.volume_pattern,
"price_shape": self.price_shape,
}
@dataclass(frozen=True, slots=True)
class PatternScore:
"""One stock-level scoring outcome independent of selection status."""
status: PatternScoreStatus = "not_executed"
value: float | None = None
threshold: float | None = None
version: str | None = None
case: PatternCaseDefinition | None = None
breakdown: PatternScoreBreakdown | None = None
reason: str | None = None
def __post_init__(self) -> None:
"""Enforce complete successful results and value-free failures."""
if self.status in {"matched", "below_threshold"}:
if (
self.value is None
or self.threshold is None
or self.version is None
or self.case is None
or self.breakdown is None
):
raise ValueError("computed pattern score requires complete match context")
_validate_score(self.value, "value")
_validate_score(self.threshold, "threshold")
if (self.value >= self.threshold) != (self.status == "matched"):
raise ValueError("pattern score status must agree with threshold")
elif any(
value is not None
for value in (self.value, self.threshold, self.version, self.case, self.breakdown)
):
raise ValueError("uncomputed pattern score cannot carry match values")
if self.status == "failed" and not (self.reason and self.reason.strip()):
raise ValueError("failed pattern score requires a safe reason")
if self.status == "not_executed" and self.reason is not None:
raise ValueError("not-executed pattern score cannot carry a reason")
@classmethod
def failed(cls, reason: str) -> PatternScore:
"""Create a safe failure without retaining raw exception details."""
normalized = " ".join(reason.split())[:500] or "pattern scoring failed"
return cls(status="failed", reason=normalized)
class PatternCaseLibraryError(RuntimeError):
"""The immutable ten-case library could not be loaded completely."""
class PatternScoringError(RuntimeError):
"""A candidate could not be scored under the versioned algorithm."""
class PatternCaseLibraryLoader(Protocol):
"""Load the complete versioned case library once for a selection run."""
def load(self) -> tuple[PatternCase, ...]: ...
class PatternScorer(Protocol):
"""Score one selected stock against an already prepared case library."""
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore: ...
class PatternFeatureExtractor:
"""Reproduce the legacy 25-row feature formulas with finite outputs."""
def extract(self, history: StockHistory) -> PatternFeatures:
"""Extract features from the latest 25 ascending, complete OHLCV rows.
Raises:
PatternScoringError: If the history does not contain exactly the
required complete window or dates are not strictly ascending.
"""
bars = history.bars[-PATTERN_LOOKBACK_DAYS:]
_validate_window(bars, history.ts_code)
frame = pd.DataFrame(
{
"open": [bar.open for bar in bars],
"high": [bar.high for bar in bars],
"low": [bar.low for bar in bars],
"close": [bar.close for bar in bars],
"volume": [bar.volume for bar in bars],
},
dtype=float,
)
white = frame["close"].ewm(span=10, adjust=False).mean()
white = white.ewm(span=10, adjust=False).mean()
yellow = (
frame["close"].rolling(14, min_periods=14).mean()
+ frame["close"].rolling(28, min_periods=28).mean()
+ frame["close"].rolling(57, min_periods=57).mean()
+ frame["close"].rolling(114, min_periods=114).mean()
) / 4.0
frame["short_term_trend"] = white
frame["bull_bear_line"] = yellow
frame = _legacy_kdj(frame)
return PatternFeatures(
trend_structure=_trend_features(frame),
kdj_state=_kdj_features(frame),
volume_pattern=_volume_features(frame),
price_shape=_price_features(frame),
)
class ZhixingB1PatternScorer:
"""Select the stable best case using working scalar FastDTW radius one."""
def __init__(self, extractor: PatternFeatureExtractor | None = None) -> None:
"""Inject an extractor for deterministic unit tests."""
self.extractor = extractor or PatternFeatureExtractor()
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore:
"""Score one selected history once against all ten ordered cases.
Raises:
PatternScoringError: If the library is incomplete or FastDTW
cannot produce a finite distance. No alternative algorithm is
used when FastDTW fails.
"""
if tuple(case.definition for case in cases) != ZHIXING_B1_PATTERN_CASES:
raise PatternScoringError("pattern case library is incomplete or out of order")
candidate = self.extractor.extract(history)
best: tuple[float, PatternCase, PatternScoreBreakdown] | None = None
for case in cases:
breakdown = _match(candidate, case.features)
value = round(
sum(
breakdown.as_dict()[name] / 100.0 * weight
for name, weight in PATTERN_WEIGHTS.items()
)
* 100.0,
2,
)
_validate_score(value, "value")
if best is None or value > best[0]:
best = (value, case, breakdown)
if best is None:
raise PatternScoringError("pattern case library is empty")
value, case, breakdown = best
return PatternScore(
status="matched" if value >= PATTERN_SCORE_THRESHOLD else "below_threshold",
value=value,
threshold=PATTERN_SCORE_THRESHOLD,
version=PATTERN_SCORING_VERSION,
case=case.definition,
breakdown=breakdown,
)
def build_pattern_case(
definition: PatternCaseDefinition,
history: StockHistory,
extractor: PatternFeatureExtractor | None = None,
) -> PatternCase:
"""Validate and precompute one versioned case for run-wide reuse."""
if history.ts_code != definition.ts_code:
raise PatternCaseLibraryError(f"case {definition.id} code does not match definition")
if len(history.bars) != definition.lookback_days:
raise PatternCaseLibraryError(
f"case {definition.id} requires {definition.lookback_days} complete rows"
)
try:
features = (extractor or PatternFeatureExtractor()).extract(history)
except PatternScoringError as exc:
raise PatternCaseLibraryError(f"case {definition.id} history is invalid") from exc
return PatternCase(definition=definition, history=history, features=features)
def _match(candidate: PatternFeatures, case: PatternFeatures) -> PatternScoreBreakdown:
return PatternScoreBreakdown(
trend_structure=round(_trend_similarity(candidate, case) * 100.0, 2),
kdj_state=round(_kdj_similarity(candidate, case) * 100.0, 2),
volume_pattern=round(_volume_similarity(candidate, case) * 100.0, 2),
price_shape=round(_price_similarity(candidate, case) * 100.0, 2),
)
def _trend_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
c, s = candidate.trend_structure, case.trend_structure
values = [
_difference_similarity(c.get("short_vs_bullbear"), s.get("short_vs_bullbear"), 0.10),
_slope_similarity(c.get("short_slope"), s.get("short_slope")),
1.0 if c.get("is_in_bowl") == s.get("is_in_bowl") else 0.2,
_difference_similarity(c.get("price_vs_short_pct"), s.get("price_vs_short_pct"), 10.0),
_difference_similarity(c.get("trend_spread_pct"), s.get("trend_spread_pct"), 10.0),
_difference_similarity(c.get("price_bias_pct"), s.get("price_bias_pct"), 10.0),
]
return float(np.mean(values))
def _kdj_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
c, s = candidate.kdj_state, case.kdj_state
values = [
1.0 if c.get("j_position") == s.get("j_position") else 0.4,
_difference_similarity(c.get("j_value"), s.get("j_value"), 30.0),
1.0 if c.get("k_cross_d") == s.get("k_cross_d") else 0.6,
1.0 if c.get("j_rebound") == s.get("j_rebound") else 0.7,
]
return float(np.mean(values))
def _volume_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
c, s = candidate.volume_pattern, case.volume_pattern
values = [
_difference_similarity(c.get("avg_volume_ratio"), s.get("avg_volume_ratio"), 1.5),
1.0 if c.get("shrink_then_expand") == s.get("shrink_then_expand") else 0.5,
1.0 if c.get("volume_trend") == s.get("volume_trend") else 0.6,
_difference_similarity(c.get("max_volume_ratio"), s.get("max_volume_ratio"), 3.0),
]
return float(np.mean(values))
def _price_similarity(candidate: PatternFeatures, case: PatternFeatures) -> float:
c, s = candidate.price_shape, case.price_shape
candidate_curve = cast(tuple[float, ...], c.get("normalized_curve"))
case_curve = cast(tuple[float, ...], s.get("normalized_curve"))
distance, _path = _fastdtw()( # radius and scalar metric are versioned behavior
candidate_curve,
case_curve,
radius=PATTERN_FASTDTW_RADIUS,
dist=_scalar_euclidean,
)
if not isfinite(float(distance)):
raise PatternScoringError("FastDTW returned a non-finite distance")
values = [
max(0.0, 1.0 - float(distance) / max(len(candidate_curve), len(case_curve))),
_difference_similarity(c.get("max_drawdown"), s.get("max_drawdown"), 15.0),
_difference_similarity(c.get("breakout_strength"), s.get("breakout_strength"), 5.0),
1.0 if c.get("overall_trend") == s.get("overall_trend") else 0.5,
_difference_similarity(c.get("consolidation_days"), s.get("consolidation_days"), 10.0),
]
return float(np.mean(values))
def _fastdtw() -> Callable[..., tuple[float, list[tuple[int, int]]]]:
"""Give the untyped extension one narrow, checked call signature."""
return cast(Callable[..., tuple[float, list[tuple[int, int]]]], fastdtw)
def _scalar_euclidean(left: float, right: float) -> float:
"""Return Euclidean distance for scalar one-dimensional curve points."""
return abs(float(left) - float(right))
def _difference_similarity(left: object, right: object, tolerance: float) -> float:
left_number = _finite_float(left)
right_number = _finite_float(right)
if left_number is None or right_number is None:
return 0.0
return max(0.0, 1.0 - abs(left_number - right_number) / tolerance)
def _slope_similarity(left: object, right: object) -> float:
left_number = _finite_float(left)
right_number = _finite_float(right)
if left_number is None or right_number is None:
return 0.0
difference = abs(left_number - right_number)
if (left_number > 0) == (right_number > 0):
return max(0.7, 1.0 - difference / 10.0)
return max(0.0, 0.3 - difference / 20.0)
def _trend_features(frame: pd.DataFrame) -> dict[str, float | bool | None]:
latest = frame.iloc[-1]
short = float(latest["short_term_trend"])
bullbear = float(latest["bull_bear_line"])
short_previous = float(frame["short_term_trend"].iloc[-5])
bullbear_previous = float(frame["bull_bear_line"].iloc[-5])
close = float(latest["close"])
average = (short + bullbear) / 2.0
return {
"short_vs_bullbear": _finite_round(short / bullbear if bullbear else 1.0, 4),
"short_slope": _finite_round(
(short / short_previous - 1.0) * 100.0 if short_previous else 0.0,
4,
),
"bullbear_slope": _finite_round(
(bullbear / bullbear_previous - 1.0) * 100.0 if bullbear_previous else 0.0,
4,
),
"price_vs_short_pct": _finite_round((close - short) / short * 100.0 if short else 0.0, 4),
"price_vs_bullbear_pct": _finite_round(
(close - bullbear) / bullbear * 100.0 if bullbear else 0.0,
4,
),
"is_in_bowl": bool(short > close > bullbear),
"trend_spread_pct": _finite_round(
(short - bullbear) / bullbear * 100.0 if bullbear else 0.0,
4,
),
"price_bias_pct": _finite_round((close - average) / average * 100.0 if average else 0.0, 4),
}
def _kdj_features(frame: pd.DataFrame) -> dict[str, float | bool | str | None]:
latest = frame.iloc[-1]
j_values = frame["J"].to_numpy(dtype=float)
recent = j_values[-5:]
j_trend = float(np.polyfit(np.arange(5), recent, 1)[0]) if np.isfinite(recent).all() else 0.0
previous = frame.iloc[-2]
j_value = float(latest["J"]) if pd.notna(latest["J"]) else 50.0
return {
"j_value": _finite_round(j_value, 2),
"j_trend": _finite_round(j_trend, 4),
"j_min_lookback": _finite_round(float(frame["J"].min()), 2),
"k_cross_d": bool(previous["K"] < previous["D"] and latest["K"] > latest["D"]),
"j_position": "低位" if j_value <= 20 else ("高位" if j_value >= 80 else "中位"),
"j_rebound": bool(j_values[-1] > j_values[-3]),
}
def _volume_features(frame: pd.DataFrame) -> dict[str, float | bool | str | int | None]:
volumes = frame["volume"].to_numpy(dtype=float)
recent_average = float(np.mean(volumes[-10:]))
before_average = float(np.mean(volumes[-20:-10]))
average_ratio = recent_average / before_average if before_average > 0 else 1.0
ratios = [
volumes[index] / volumes[index - 1] for index in range(1, 20) if volumes[index - 1] > 0
]
midpoint = len(volumes) // 2
early, late = float(np.mean(volumes[:midpoint])), float(np.mean(volumes[midpoint:]))
shrink_expand = bool(late > early * 1.3 and early < float(np.mean(volumes)) * 0.9)
key_count = sum(
1
for index in range(1, len(frame))
if frame["volume"].iloc[index] > frame["volume"].iloc[index - 1] * 2
and frame["close"].iloc[index] > frame["open"].iloc[index]
)
slope = float(np.polyfit(np.arange(len(volumes)), volumes, 1)[0])
slope_pct = slope / float(np.mean(volumes)) * 100.0 if float(np.mean(volumes)) > 0 else 0.0
trend = (
"持续放量"
if slope_pct > 5
else "持续缩量"
if slope_pct < -5
else "缩量后放量"
if shrink_expand
else "量能平稳"
)
return {
"avg_volume_ratio": _finite_round(average_ratio, 2),
"max_volume_ratio": _finite_round(max(ratios, default=1.0), 2),
"volume_trend": trend,
"key_candles_count": key_count,
"shrink_then_expand": shrink_expand,
}
def _price_features(frame: pd.DataFrame) -> dict[str, float | str | int | tuple[float, ...] | None]:
closes = frame["close"].to_numpy(dtype=float)
minimum, maximum = float(closes.min()), float(closes.max())
normalized = (
tuple(float(value) for value in (closes - minimum) / (maximum - minimum))
if maximum > minimum
else (0.0,) * len(closes)
)
peak = np.maximum.accumulate(closes)
max_drawdown = float(((peak - closes) / peak).max()) * 100.0
breakout = (closes[-1] / closes[-2] - 1.0) * 100.0
returns = np.diff(closes) / closes[:-1]
volatility = float(np.std(returns)) * 100.0
consolidation, current = 0, 0
for index in range(len(frame) - 5):
window = closes[index : index + 5]
if window.max() > 0 and (window.max() - window.min()) / window.max() < 0.05:
current += 1
consolidation = max(consolidation, current)
else:
current = 0
trend = (
"上升"
if closes[-1] > closes[0] * 1.05
else "下降"
if closes[-1] < closes[0] * 0.95
else "震荡"
)
return {
"consolidation_days": consolidation,
"max_drawdown": _finite_round(max_drawdown, 2),
"breakout_strength": _finite_round(breakout, 2),
"normalized_curve": normalized,
"volatility": _finite_round(volatility, 4),
"overall_trend": trend,
}
def _legacy_kdj(frame: pd.DataFrame) -> pd.DataFrame:
low = frame["low"].rolling(window=9, min_periods=1).min()
high = frame["high"].rolling(window=9, min_periods=1).max()
rsv = ((frame["close"] - low) / (high - low + 1e-9) * 100.0).to_numpy(dtype=float)
k = np.empty(len(rsv), dtype=float)
d = np.empty(len(rsv), dtype=float)
k[0] = d[0] = 50.0
for index in range(1, len(rsv)):
k[index] = 2.0 / 3.0 * k[index - 1] + 1.0 / 3.0 * rsv[index]
d[index] = 2.0 / 3.0 * d[index - 1] + 1.0 / 3.0 * k[index]
return frame.assign(K=k, D=d, J=3.0 * k - 2.0 * d)
def _validate_window(bars: Sequence[SelectionBar], ts_code: str) -> None:
if len(bars) != PATTERN_LOOKBACK_DAYS:
raise PatternScoringError(f"{ts_code} requires {PATTERN_LOOKBACK_DAYS} complete rows")
if any(
left.trade_date >= right.trade_date for left, right in zip(bars, bars[1:], strict=False)
):
raise PatternScoringError(f"{ts_code} pattern rows must be strictly ascending")
if any(
value is None
for bar in bars
for value in (bar.open, bar.high, bar.low, bar.close, bar.volume)
):
raise PatternScoringError(f"{ts_code} pattern rows require complete OHLCV")
def _finite_float(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, Real):
return None
number = float(value)
return number if isfinite(number) else None
def _finite_round(value: float, digits: int) -> float | None:
return round(float(value), digits) if isfinite(float(value)) else None
def _validate_score(value: float, name: str) -> None:
if not isfinite(value) or value < 0 or value > 100:
raise ValueError(f"{name} must be finite and between 0 and 100")
__all__ = [
"PATTERN_FASTDTW_RADIUS",
"PATTERN_LOOKBACK_DAYS",
"PATTERN_SCORE_THRESHOLD",
"PATTERN_SCORING_VERSION",
"PatternCase",
"PatternCaseDefinition",
"PatternCaseLibraryError",
"PatternCaseLibraryLoader",
"PatternFeatureExtractor",
"PatternFeatures",
"PatternScore",
"PatternScoreBreakdown",
"PatternScorer",
"PatternScoringError",
"ZHIXING_B1_PATTERN_CASES",
"ZhixingB1PatternScorer",
"build_pattern_case",
]
@@ -9,10 +9,12 @@ from decimal import Decimal
from typing import Literal, Protocol
from .models import SelectionEvaluationStatus, SelectionSignal, StockHistory
from .pattern_scoring import PatternScore
SelectionRunStatus = Literal["running", "success", "partial_success", "failed"]
SelectionRunItemStatus = SelectionEvaluationStatus
SelectionSignalCategoryFilter = Literal["pullback", "oversold", "original"]
SelectionResultSort = Literal["code", "score_desc", "score_asc"]
@dataclass(frozen=True, slots=True)
@@ -23,6 +25,7 @@ class SelectionResultQuery:
page_size: int = 10
search: str | None = None
category: SelectionSignalCategoryFilter | None = None
sort: SelectionResultSort = "code"
@dataclass(frozen=True, slots=True)
@@ -54,6 +57,7 @@ class SelectionRunItem:
status: SelectionRunItemStatus
signal_count: int = 0
reason: str | None = None
pattern_score: PatternScore = field(default_factory=PatternScore)
signals: tuple[SelectionSignal, ...] = field(default_factory=tuple)
@@ -12,6 +12,12 @@ import psycopg
from ....bootstrap.config import Settings
from ..domain.models import SelectionBar, SelectionDailyBasic, StockHistory
from ..domain.pattern_scoring import (
ZHIXING_B1_PATTERN_CASES,
PatternCase,
PatternCaseLibraryError,
build_pattern_case,
)
from ..domain.ports import MarketDataReaderError
from ..domain.runs import SelectionExecutionSource, SelectionStock
from .postgres_pool import SelectionConnectionPool, SelectionPostgresPool
@@ -103,6 +109,38 @@ WHERE stock.is_active = true
ORDER BY stock.ts_code
"""
_PATTERN_CASES_QUERY = """
WITH case_definition AS (
SELECT *
FROM unnest(%s::text[], %s::text[], %s::date[], %s::integer[])
AS definition(case_id, ts_code, breakout_date, lookback_days)
), ranked AS (
SELECT
definition.case_id,
bar.ts_code,
bar.trade_date,
bar.open,
bar.high,
bar.low,
bar.close,
bar.vol,
row_number() OVER (
PARTITION BY definition.case_id
ORDER BY bar.trade_date DESC
) AS recency_rank,
definition.lookback_days
FROM case_definition AS definition
JOIN market_daily_bar AS bar
ON bar.ts_code = definition.ts_code
AND bar.source_adj = 'qfq'
AND bar.trade_date < definition.breakout_date
)
SELECT case_id, ts_code, trade_date, open, high, low, close, vol
FROM ranked
WHERE recency_rank <= lookback_days
ORDER BY case_id ASC, trade_date ASC
"""
def _as_date(value: object) -> date:
"""Convert a PostgreSQL date-like scalar to a date."""
@@ -386,3 +424,106 @@ class PostgresMarketDataReader:
raise
except Exception as exc: # noqa: BLE001 - normalize pool/driver failures
raise SelectionReaderError("selection database operation failed") from exc
class PostgresPatternCaseLibraryLoader:
"""Build the complete versioned FastDTW case library from PostgreSQL qfq bars."""
def __init__(
self,
settings: Settings | str,
*,
pool: SelectionPostgresPool | SelectionConnectionPool | None = None,
) -> None:
"""Create a loader sharing the process selection connection pool."""
self.database_url = settings.database_url if isinstance(settings, Settings) else settings
if isinstance(pool, SelectionPostgresPool):
self.pool: SelectionPostgresPool | None = pool
elif pool is not None:
self.pool = SelectionPostgresPool(self.database_url, max_connections=1, pool=pool)
else:
self.pool = None
def load(self) -> tuple[PatternCase, ...]:
"""Load all ten exclusive pre-breakout windows exactly once.
Returns:
Ordered, feature-precomputed cases matching the versioned definitions.
Raises:
PatternCaseLibraryError: If the query fails or any case lacks a
complete finite 25-row qfq window.
"""
definitions = ZHIXING_B1_PATTERN_CASES
parameters = (
[definition.id for definition in definitions],
[definition.ts_code for definition in definitions],
[definition.breakout_date for definition in definitions],
[definition.lookback_days for definition in definitions],
)
try:
with self._connection() as connection:
rows = connection.execute(_PATTERN_CASES_QUERY, parameters).fetchall()
rows_by_case: dict[str, list[tuple[object, ...]]] = {
definition.id: [] for definition in definitions
}
for raw_row in rows:
row = cast(tuple[object, ...], raw_row)
case_id = str(row[0])
if case_id not in rows_by_case:
raise PatternCaseLibraryError(f"unexpected pattern case row: {case_id}")
rows_by_case[case_id].append(row)
cases: list[PatternCase] = []
for definition in definitions:
case_rows = rows_by_case[definition.id]
if len(case_rows) != definition.lookback_days:
raise PatternCaseLibraryError(
f"case {definition.id} requires {definition.lookback_days} qfq rows"
)
bars = tuple(
SelectionBar(
trade_date=_as_date(row[2]),
open=_as_float(row[3]),
high=_as_float(row[4]),
low=_as_float(row[5]),
close=_as_float(row[6]),
volume=_as_float(row[7]),
)
for row in case_rows
)
if any(bar.trade_date >= definition.breakout_date for bar in bars):
raise PatternCaseLibraryError(
f"case {definition.id} contains a non-exclusive breakout row"
)
history = StockHistory(
ts_code=definition.ts_code,
name=definition.name,
bars=bars,
)
cases.append(build_pattern_case(definition, history))
return tuple(cases)
except PatternCaseLibraryError:
raise
except Exception as exc: # noqa: BLE001 - redact database details at the port boundary
raise PatternCaseLibraryError(
"failed to load the complete pattern case library"
) from exc
@contextmanager
def _connection(self) -> Generator[Any, None, None]:
"""Borrow a shared connection without exposing driver failures."""
try:
if self.pool is None:
with psycopg.connect(self.database_url) as connection:
yield connection
else:
with self.pool.connection() as connection:
yield connection
except PatternCaseLibraryError:
raise
except Exception as exc: # noqa: BLE001 - normalize driver/pool errors
raise PatternCaseLibraryError("pattern case database operation failed") from exc
@@ -15,6 +15,11 @@ import psycopg
from psycopg.types.json import Jsonb
from ..domain.models import SelectionSignal, ZhixingB1Category
from ..domain.pattern_scoring import (
ZHIXING_B1_PATTERN_CASES,
PatternScore,
PatternScoreBreakdown,
)
from ..domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
@@ -44,17 +49,37 @@ _SIGNAL_ORDER_SQL = (
)
+ f" ELSE {len(ZHIXING_B1_SIGNAL_ORDER)} END"
)
_PATTERN_CASES_BY_ID = {definition.id: definition for definition in ZHIXING_B1_PATTERN_CASES}
_STOCK_ORDER_SQL = {
"code": "item.ts_code ASC",
"score_desc": "item.score_value DESC NULLS LAST, item.ts_code ASC",
"score_asc": "item.score_value ASC NULLS LAST, item.ts_code ASC",
}
_ITEM_UPSERT = """
INSERT INTO selection_run_item
(run_id, ts_code, name, status, signal_count, reason)
VALUES (%s, %s, %s, %s, %s, %s)
(
run_id, ts_code, name, status, signal_count, reason,
score_status, score_value, score_threshold, score_version,
match_case_id, match_case_name, match_case_breakout_date,
match_breakdown, score_reason
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (run_id, ts_code) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
signal_count = EXCLUDED.signal_count,
reason = EXCLUDED.reason
reason = EXCLUDED.reason,
score_status = EXCLUDED.score_status,
score_value = EXCLUDED.score_value,
score_threshold = EXCLUDED.score_threshold,
score_version = EXCLUDED.score_version,
match_case_id = EXCLUDED.match_case_id,
match_case_name = EXCLUDED.match_case_name,
match_case_breakout_date = EXCLUDED.match_case_breakout_date,
match_breakdown = EXCLUDED.match_breakdown,
score_reason = EXCLUDED.score_reason
"""
_SIGNAL_UPSERT = """
INSERT INTO selection_signal
@@ -200,6 +225,19 @@ class PostgresSelectionRunRepository(SelectionRunStore):
item.status,
item.signal_count,
item.reason,
item.pattern_score.status,
item.pattern_score.value,
item.pattern_score.threshold,
item.pattern_score.version,
item.pattern_score.case.id if item.pattern_score.case else None,
item.pattern_score.case.name if item.pattern_score.case else None,
item.pattern_score.case.breakout_date if item.pattern_score.case else None,
(
Jsonb(item.pattern_score.breakdown.as_dict())
if item.pattern_score.breakdown
else None
),
item.pattern_score.reason,
)
for item in items
)
@@ -354,7 +392,11 @@ class PostgresSelectionRunRepository(SelectionRunStore):
return None
item_rows = connection.execute(
"""
SELECT ts_code, name, status, signal_count, reason
SELECT
ts_code, name, status, signal_count, reason,
score_status, score_value, score_threshold, score_version,
match_case_id, match_case_name, match_case_breakout_date,
match_breakdown, score_reason
FROM selection_run_item
WHERE run_id = %s
ORDER BY ts_code
@@ -363,7 +405,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
).fetchall()
stock_filter, stock_parameters = _stock_filter(query, run_id)
stock_total_row = connection.execute(
f"SELECT COUNT(DISTINCT ts_code) FROM selection_signal WHERE {stock_filter}",
f"SELECT COUNT(*) FROM selection_run_item AS item WHERE {stock_filter}",
tuple(stock_parameters),
).fetchone()
stock_total = int(stock_total_row[0] or 0) if stock_total_row else 0
@@ -372,10 +414,10 @@ class PostgresSelectionRunRepository(SelectionRunStore):
list[tuple[object, ...]],
connection.execute(
f"""
SELECT DISTINCT ts_code
FROM selection_signal
SELECT item.ts_code
FROM selection_run_item AS item
WHERE {stock_filter}
ORDER BY ts_code
ORDER BY {_STOCK_ORDER_SQL[query.sort]}
LIMIT %s OFFSET %s
""",
tuple((*stock_parameters, query.page_size, offset)),
@@ -403,7 +445,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
sorted(
(_signal_from_row(value) for value in signal_rows),
key=lambda signal: (
signal.ts_code,
stock_codes.index(signal.ts_code),
_SIGNAL_PRIORITY.get(signal.category, len(_SIGNAL_PRIORITY)),
),
)
@@ -427,6 +469,7 @@ class PostgresSelectionRunRepository(SelectionRunStore):
),
signal_count=int(value[3] or 0),
reason=str(value[4]) if value[4] is not None else None,
pattern_score=_pattern_score_from_row(value[5:14]),
signals=tuple(signals_by_stock.get(str(value[0]), ())),
)
for value in item_rows
@@ -509,18 +552,76 @@ def _stock_filter(query: SelectionResultQuery, run_id: str) -> tuple[str, list[o
can present all independently persisted categories together.
"""
clauses = ["run_id = %s"]
clauses = ["item.run_id = %s", "item.status = 'selected'", "item.signal_count > 0"]
parameters: list[object] = [run_id]
if query.search:
pattern = f"%{_escape_like(query.search)}%"
clauses.append("(name ILIKE %s ESCAPE '\\' OR ts_code ILIKE %s ESCAPE '\\')")
clauses.append("(item.name ILIKE %s ESCAPE '\\' OR item.ts_code ILIKE %s ESCAPE '\\')")
parameters.extend((pattern, pattern))
if query.category:
clauses.append("category LIKE %s")
clauses.append(
"EXISTS ("
"SELECT 1 FROM selection_signal AS signal "
"WHERE signal.run_id = item.run_id "
"AND signal.ts_code = item.ts_code "
"AND signal.category LIKE %s"
")"
)
parameters.append(f"{_CATEGORY_PREFIXES[query.category]}%")
return " AND ".join(clauses), parameters
def _pattern_score_from_row(row: Sequence[object]) -> PatternScore:
"""Reconstruct a validated stock-level score from nullable item columns."""
if len(row) < 9:
return PatternScore()
status = str(row[0] or "not_executed")
if status == "not_executed":
return PatternScore()
if status == "failed":
return PatternScore.failed(str(row[8] or "pattern scoring failed"))
if status not in {"matched", "below_threshold"}:
return PatternScore.failed("persisted pattern score status is invalid")
definition = _PATTERN_CASES_BY_ID.get(str(row[4]))
breakdown = _pattern_breakdown(row[7])
if definition is None or breakdown is None:
return PatternScore.failed("persisted pattern score is incomplete")
try:
return PatternScore(
status=cast(Literal["matched", "below_threshold"], status),
value=float(str(row[1])),
threshold=float(str(row[2])),
version=str(row[3]),
case=definition,
breakdown=breakdown,
)
except (TypeError, ValueError):
return PatternScore.failed("persisted pattern score is invalid")
def _pattern_breakdown(value: object) -> PatternScoreBreakdown | None:
"""Parse the four finite JSONB score dimensions."""
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
if not isinstance(value, Mapping):
return None
values = cast(Mapping[object, object], value)
try:
return PatternScoreBreakdown(
trend_structure=float(str(values["trend_structure"])),
kdj_state=float(str(values["kdj_state"])),
volume_pattern=float(str(values["volume_pattern"])),
price_shape=float(str(values["price_shape"])),
)
except (KeyError, TypeError, ValueError):
return None
def _escape_like(value: str) -> str:
"""Escape user wildcards before placing text inside a SQL LIKE pattern."""
@@ -13,6 +13,10 @@ from zhixing_server.modules.selection.application.run import (
RunZhixingB1,
)
from zhixing_server.modules.selection.domain.models import SelectionSignal
from zhixing_server.modules.selection.domain.pattern_scoring import (
PatternScore,
ZhixingB1PatternScorer,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionRerunRequired,
SelectionResultQuery,
@@ -23,6 +27,7 @@ from zhixing_server.modules.selection.domain.runs import (
from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader,
PostgresPatternCaseLibraryLoader,
SelectionMarketDataNotReady,
SelectionReaderError,
)
@@ -82,6 +87,35 @@ class SelectionFailureResponse(BaseModel):
reason: str | None
class SelectionPatternCaseResponse(BaseModel):
"""The best matching versioned case for one computed score."""
id: str
name: str
breakout_date: date
class SelectionPatternBreakdownResponse(BaseModel):
"""The four finite 0-100 similarity dimensions."""
trend_structure: float = Field(ge=0, le=100)
kdj_state: float = Field(ge=0, le=100)
volume_pattern: float = Field(ge=0, le=100)
price_shape: float = Field(ge=0, le=100)
class SelectionPatternScoreResponse(BaseModel):
"""A stock-level enrichment independent of selection evaluation status."""
status: Literal["matched", "below_threshold", "failed"]
value: float | None = Field(default=None, ge=0, le=100)
threshold: float | None = Field(default=None, ge=0, le=100)
version: str | None = None
case: SelectionPatternCaseResponse | None = None
breakdown: SelectionPatternBreakdownResponse | None = None
reason: str | None = None
def _empty_failures() -> list[SelectionFailureResponse]:
"""Create a typed default list for Pydantic's strict checker."""
@@ -102,6 +136,7 @@ class SelectionStockResponse(BaseModel):
target_trade_date: date
strategy: StrategyValue
close: float
score: SelectionPatternScoreResponse | None = None
signals: list[SelectionSignalResponse] = Field(default_factory=_empty_signals)
@@ -144,10 +179,14 @@ def get_selection_service(
pool = get_selection_postgres_pool(settings)
reader = PostgresMarketDataReader(settings, pool=pool)
pattern_case_loader = PostgresPatternCaseLibraryLoader(settings, pool=pool)
store = PostgresSelectionRunRepository(settings.database_url, pool=pool)
return RunZhixingB1(
reader,
store,
pattern_case_loader=pattern_case_loader,
pattern_scorer=ZhixingB1PatternScorer(),
pattern_scoring_enabled=settings.selection_pattern_scoring_enabled,
max_workers=settings.selection_max_workers,
batch_size=settings.selection_batch_size,
)
@@ -225,11 +264,12 @@ def get_selection_run(
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None,
sort: Literal["code", "score_desc", "score_asc"] = "code",
) -> SelectionResultsResponse:
"""Return one run for asynchronous polling."""
try:
query = _result_query(page, page_size, search, category)
query = _result_query(page, page_size, search, category, sort)
run = service.get_run(run_id, query=query)
except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
@@ -247,11 +287,12 @@ def get_selection_results(
page_size: Annotated[int, Query(ge=1, le=100)] = 10,
search: Annotated[str | None, Query(max_length=100)] = None,
category: Literal["pullback", "oversold", "original"] | None = None,
sort: Literal["code", "score_desc", "score_asc"] = "code",
) -> SelectionResultsResponse:
"""Return the current persisted result for a strategy and optional date."""
try:
query = _result_query(page, page_size, search, category)
query = _result_query(page, page_size, search, category, sort)
run = service.get_latest(strategy, target_trade_date, query=query)
except SelectionRunStoreError as exc:
raise _http_error(503, "selection_storage_unavailable", str(exc)) from exc
@@ -276,6 +317,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
signals_by_stock: dict[str, list[SelectionSignalResponse]] = {}
for signal in run.signals:
signals_by_stock.setdefault(signal.ts_code, []).append(_signal_response(signal))
items_by_stock = {item.ts_code: item for item in run.items}
return SelectionResultsResponse(
strategy=run.strategy,
@@ -316,6 +358,7 @@ def _run_response(run: SelectionRun, *, query: SelectionResultQuery) -> Selectio
target_trade_date=signals[0].target_trade_date,
strategy=signals[0].strategy,
close=signals[0].close,
score=_pattern_score_response(items_by_stock[signals[0].ts_code].pattern_score),
signals=signals,
)
for signals in signals_by_stock.values()
@@ -337,11 +380,43 @@ def _signal_response(signal: SelectionSignal) -> SelectionSignalResponse:
)
def _pattern_score_response(score: PatternScore) -> SelectionPatternScoreResponse | None:
"""Hide not-executed scores and expose validated computed/failure states."""
if score.status == "not_executed":
return None
if score.status == "failed":
return SelectionPatternScoreResponse(status="failed", reason=score.reason)
if score.status == "below_threshold":
return SelectionPatternScoreResponse(
status="below_threshold",
threshold=score.threshold,
version=score.version,
reason="未匹配到评分阈值以上案例",
)
if score.case is None or score.breakdown is None:
return SelectionPatternScoreResponse(status="failed", reason="评分结果不完整")
return SelectionPatternScoreResponse(
status=score.status,
value=score.value,
threshold=score.threshold,
version=score.version,
case=SelectionPatternCaseResponse(
id=score.case.id,
name=score.case.name,
breakout_date=score.case.breakout_date,
),
breakdown=SelectionPatternBreakdownResponse(**score.breakdown.as_dict()),
reason=score.reason,
)
def _result_query(
page: int,
page_size: int,
search: str | None,
category: Literal["pullback", "oversold", "original"] | None,
sort: Literal["code", "score_desc", "score_asc"],
) -> SelectionResultQuery:
"""Normalize HTTP query values before handing them to the selection port."""
@@ -351,6 +426,7 @@ def _result_query(
page_size=page_size,
search=normalized_search or None,
category=category,
sort=sort,
)
@@ -0,0 +1,5 @@
# FastDTW v1 离线基线
十个 CSV 仅保留原项目固定案例突破日前最后 25 个升序交易日,测试运行不读取原项目、网络或数据库。`golden.json` 使用修正后可工作的 FastDTW、标量欧氏距离与 `radius=1` 离线生成;它有意不兼容原项目实际执行的 simple-DTW fallback。
25 行窗口不足以产生 114 日多空线。领域 extractor 将这些旧公式产生的非有限中间值显式转换为 `None`,matcher 按旧比较的最终效果记为零相似度,保证 dataclass、JSONB 和 HTTP 不包含 `NaN`/`Infinity`。案例库现在要求十例各 25 行完整 OHLCV,不再静默接受部分案例。
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-04-01,27.93,29.03,27.8,28.94,27218.84,5612754000
2025-04-02,28.9,29.18,28.69,28.94,12933.01,5612754000
2025-04-03,28.71,29.07,28.54,28.73,11232.75,5612754000
2025-04-07,27.93,27.93,23.19,24.02,37675.08,5612754000
2025-04-08,24.03,25.03,24.03,24.86,16676.65,5612754000
2025-04-09,24.49,24.87,23.12,24.73,14468.45,5612754000
2025-04-10,24.96,25.53,24.89,25.1,11065.87,5612754000
2025-04-11,25.02,25.91,24.7,25.67,11201.14,5612754000
2025-04-14,25.75,26.88,25.75,26.29,14566.38,5612754000
2025-04-15,26.41,27.19,26.09,26.17,10132.02,5612754000
2025-04-16,26.04,26.71,25.88,26.38,13525.38,5612754000
2025-04-17,26.11,28.69,26.04,28.39,42729.26,5612754000
2025-04-18,28.83,29.54,27.91,28.51,48214.96,5612754000
2025-04-21,28.83,31.25,27.94,30.52,96121.97,5612754000
2025-04-22,30.52,35.02,30.52,32.77,148408.79,5612754000
2025-04-23,32.43,33.71,30.92,32.34,56626.78,5612754000
2025-04-24,32.44,34.67,32.44,34.18,48618.11,5612754000
2025-04-25,34.18,34.55,30.24,30.67,75477.69,5612754000
2025-04-28,30.93,32.63,30.24,31.15,62031.09,5612754000
2025-04-29,31.8,32.54,30.97,31.39,34211.51,5612754000
2025-04-30,32.02,32.02,29.92,30.19,48359.44,5612754000
2025-05-06,30.24,30.62,29.2,29.5,36216.67,5612754000
2025-05-07,29.67,30.34,29.18,29.54,26316.22,5612754000
2025-05-08,29.54,29.94,29.26,29.82,20883.91,5612754000
2025-05-09,29.82,30.44,29.32,29.44,16659.71,5612754000
1 date open high low close volume market_cap
2 2025-04-01 27.93 29.03 27.8 28.94 27218.84 5612754000
3 2025-04-02 28.9 29.18 28.69 28.94 12933.01 5612754000
4 2025-04-03 28.71 29.07 28.54 28.73 11232.75 5612754000
5 2025-04-07 27.93 27.93 23.19 24.02 37675.08 5612754000
6 2025-04-08 24.03 25.03 24.03 24.86 16676.65 5612754000
7 2025-04-09 24.49 24.87 23.12 24.73 14468.45 5612754000
8 2025-04-10 24.96 25.53 24.89 25.1 11065.87 5612754000
9 2025-04-11 25.02 25.91 24.7 25.67 11201.14 5612754000
10 2025-04-14 25.75 26.88 25.75 26.29 14566.38 5612754000
11 2025-04-15 26.41 27.19 26.09 26.17 10132.02 5612754000
12 2025-04-16 26.04 26.71 25.88 26.38 13525.38 5612754000
13 2025-04-17 26.11 28.69 26.04 28.39 42729.26 5612754000
14 2025-04-18 28.83 29.54 27.91 28.51 48214.96 5612754000
15 2025-04-21 28.83 31.25 27.94 30.52 96121.97 5612754000
16 2025-04-22 30.52 35.02 30.52 32.77 148408.79 5612754000
17 2025-04-23 32.43 33.71 30.92 32.34 56626.78 5612754000
18 2025-04-24 32.44 34.67 32.44 34.18 48618.11 5612754000
19 2025-04-25 34.18 34.55 30.24 30.67 75477.69 5612754000
20 2025-04-28 30.93 32.63 30.24 31.15 62031.09 5612754000
21 2025-04-29 31.8 32.54 30.97 31.39 34211.51 5612754000
22 2025-04-30 32.02 32.02 29.92 30.19 48359.44 5612754000
23 2025-05-06 30.24 30.62 29.2 29.5 36216.67 5612754000
24 2025-05-07 29.67 30.34 29.18 29.54 26316.22 5612754000
25 2025-05-08 29.54 29.94 29.26 29.82 20883.91 5612754000
26 2025-05-09 29.82 30.44 29.32 29.44 16659.71 5612754000
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-07-02,10.88,11.1,10.57,10.67,1287084.86,12045490456
2025-07-03,10.62,10.9,10.54,10.82,991702.0,12045490456
2025-07-04,10.83,10.88,10.4,10.45,855100.82,12045490456
2025-07-07,10.28,11.26,10.28,10.98,1216456.82,12045490456
2025-07-08,10.89,11.52,10.81,11.15,1558398.35,12045490456
2025-07-09,11.18,11.23,10.79,10.84,1033990.38,12045490456
2025-07-10,11.27,11.86,10.93,11.64,2056513.24,12045490456
2025-07-11,11.87,12.46,11.53,12.13,2320402.62,12045490456
2025-07-14,12.19,12.46,11.57,11.62,1491417.84,12045490456
2025-07-15,11.58,12.78,11.58,12.2,2460847.71,12045490456
2025-07-16,11.98,12.04,11.27,11.32,1938934.42,12045490456
2025-07-17,11.08,11.49,10.97,11.4,1018625.42,12045490456
2025-07-18,11.34,12.14,11.32,11.68,1574602.28,12045490456
2025-07-21,11.6,11.98,11.57,11.8,1226347.09,12045490456
2025-07-22,11.68,12.0,11.47,11.56,985223.02,12045490456
2025-07-23,11.46,11.73,11.2,11.5,751845.98,12045490456
2025-07-24,11.42,12.4,11.39,12.27,1884541.46,12045490456
2025-07-25,12.22,13.06,12.12,12.61,1848357.03,12045490456
2025-07-28,12.91,12.97,12.61,12.69,1106575.15,12045490456
2025-07-29,12.41,12.64,12.28,12.4,794365.97,12045490456
2025-07-30,12.38,12.44,11.83,12.09,880349.27,12045490456
2025-07-31,11.97,12.13,11.75,11.81,547576.88,12045490456
2025-08-01,11.8,11.8,11.54,11.58,448552.57,12045490456
2025-08-04,11.6,11.68,11.51,11.63,404376.06,12045490456
2025-08-05,11.8,11.89,11.63,11.68,518346.76,12045490456
1 date open high low close volume market_cap
2 2025-07-02 10.88 11.1 10.57 10.67 1287084.86 12045490456
3 2025-07-03 10.62 10.9 10.54 10.82 991702.0 12045490456
4 2025-07-04 10.83 10.88 10.4 10.45 855100.82 12045490456
5 2025-07-07 10.28 11.26 10.28 10.98 1216456.82 12045490456
6 2025-07-08 10.89 11.52 10.81 11.15 1558398.35 12045490456
7 2025-07-09 11.18 11.23 10.79 10.84 1033990.38 12045490456
8 2025-07-10 11.27 11.86 10.93 11.64 2056513.24 12045490456
9 2025-07-11 11.87 12.46 11.53 12.13 2320402.62 12045490456
10 2025-07-14 12.19 12.46 11.57 11.62 1491417.84 12045490456
11 2025-07-15 11.58 12.78 11.58 12.2 2460847.71 12045490456
12 2025-07-16 11.98 12.04 11.27 11.32 1938934.42 12045490456
13 2025-07-17 11.08 11.49 10.97 11.4 1018625.42 12045490456
14 2025-07-18 11.34 12.14 11.32 11.68 1574602.28 12045490456
15 2025-07-21 11.6 11.98 11.57 11.8 1226347.09 12045490456
16 2025-07-22 11.68 12.0 11.47 11.56 985223.02 12045490456
17 2025-07-23 11.46 11.73 11.2 11.5 751845.98 12045490456
18 2025-07-24 11.42 12.4 11.39 12.27 1884541.46 12045490456
19 2025-07-25 12.22 13.06 12.12 12.61 1848357.03 12045490456
20 2025-07-28 12.91 12.97 12.61 12.69 1106575.15 12045490456
21 2025-07-29 12.41 12.64 12.28 12.4 794365.97 12045490456
22 2025-07-30 12.38 12.44 11.83 12.09 880349.27 12045490456
23 2025-07-31 11.97 12.13 11.75 11.81 547576.88 12045490456
24 2025-08-01 11.8 11.8 11.54 11.58 448552.57 12045490456
25 2025-08-04 11.6 11.68 11.51 11.63 404376.06 12045490456
26 2025-08-05 11.8 11.89 11.63 11.68 518346.76 12045490456
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-05-15,17.33,17.42,17.1,17.25,20642.05,11720226798
2025-05-16,17.18,17.73,17.17,17.43,35369.48,11720226798
2025-05-19,17.48,17.48,17.08,17.25,25489.78,11720226798
2025-05-20,17.36,17.74,17.32,17.53,36708.2,11720226798
2025-05-21,17.72,18.22,17.47,17.72,41463.24,11720226798
2025-05-22,17.62,17.81,17.37,17.58,40314.58,11720226798
2025-05-23,17.52,17.97,17.47,17.51,46281.21,11720226798
2025-05-26,17.63,17.63,17.05,17.09,38830.29,11720226798
2025-05-27,17.17,17.32,17.0,17.16,42731.45,11720226798
2025-05-28,17.17,18.4,17.08,18.21,123423.01,11720226798
2025-05-29,18.44,20.16,18.36,19.65,194317.79,11720226798
2025-05-30,19.74,19.96,19.39,19.76,132173.99,11720226798
2025-06-03,19.86,22.94,19.85,22.36,290301.1,11720226798
2025-06-04,22.17,22.76,21.58,22.54,199596.74,11720226798
2025-06-05,22.54,23.42,21.96,23.31,231289.99,11720226798
2025-06-06,23.01,23.11,21.66,22.86,233436.91,11720226798
2025-06-09,22.76,24.44,22.76,23.71,261851.09,11720226798
2025-06-10,23.69,23.82,22.66,22.81,190046.45,11720226798
2025-06-11,22.89,23.06,22.32,22.37,116651.04,11720226798
2025-06-12,22.64,24.05,22.18,23.28,190460.15,11720226798
2025-06-13,23.16,23.64,22.72,22.88,106830.32,11720226798
2025-06-16,22.88,23.32,22.51,22.75,70989.1,11720226798
2025-06-17,23.23,23.41,21.97,22.18,139623.83,11720226798
2025-06-18,21.85,22.29,21.61,22.22,100081.97,11720226798
2025-06-19,22.22,22.49,21.31,21.44,76485.29,11720226798
1 date open high low close volume market_cap
2 2025-05-15 17.33 17.42 17.1 17.25 20642.05 11720226798
3 2025-05-16 17.18 17.73 17.17 17.43 35369.48 11720226798
4 2025-05-19 17.48 17.48 17.08 17.25 25489.78 11720226798
5 2025-05-20 17.36 17.74 17.32 17.53 36708.2 11720226798
6 2025-05-21 17.72 18.22 17.47 17.72 41463.24 11720226798
7 2025-05-22 17.62 17.81 17.37 17.58 40314.58 11720226798
8 2025-05-23 17.52 17.97 17.47 17.51 46281.21 11720226798
9 2025-05-26 17.63 17.63 17.05 17.09 38830.29 11720226798
10 2025-05-27 17.17 17.32 17.0 17.16 42731.45 11720226798
11 2025-05-28 17.17 18.4 17.08 18.21 123423.01 11720226798
12 2025-05-29 18.44 20.16 18.36 19.65 194317.79 11720226798
13 2025-05-30 19.74 19.96 19.39 19.76 132173.99 11720226798
14 2025-06-03 19.86 22.94 19.85 22.36 290301.1 11720226798
15 2025-06-04 22.17 22.76 21.58 22.54 199596.74 11720226798
16 2025-06-05 22.54 23.42 21.96 23.31 231289.99 11720226798
17 2025-06-06 23.01 23.11 21.66 22.86 233436.91 11720226798
18 2025-06-09 22.76 24.44 22.76 23.71 261851.09 11720226798
19 2025-06-10 23.69 23.82 22.66 22.81 190046.45 11720226798
20 2025-06-11 22.89 23.06 22.32 22.37 116651.04 11720226798
21 2025-06-12 22.64 24.05 22.18 23.28 190460.15 11720226798
22 2025-06-13 23.16 23.64 22.72 22.88 106830.32 11720226798
23 2025-06-16 22.88 23.32 22.51 22.75 70989.1 11720226798
24 2025-06-17 23.23 23.41 21.97 22.18 139623.83 11720226798
25 2025-06-18 21.85 22.29 21.61 22.22 100081.97 11720226798
26 2025-06-19 22.22 22.49 21.31 21.44 76485.29 11720226798
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-06-18,4.65,4.86,4.6,4.83,2051281.76,39275697251
2025-06-19,4.79,4.98,4.75,4.78,1715941.43,39275697251
2025-06-20,4.77,4.81,4.63,4.65,1051952.17,39275697251
2025-06-23,4.6,4.75,4.57,4.7,934722.87,39275697251
2025-06-24,4.72,4.81,4.7,4.78,945714.0,39275697251
2025-06-25,4.8,4.85,4.73,4.81,1124786.7,39275697251
2025-06-26,4.86,5.05,4.83,4.94,2459293.2,39275697251
2025-06-27,4.94,5.42,4.85,5.27,4029657.48,39275697251
2025-06-30,5.25,5.43,5.25,5.34,2441261.2,39275697251
2025-07-01,5.31,5.38,5.24,5.3,1702111.13,39275697251
2025-07-02,5.26,5.28,5.05,5.08,1565861.38,39275697251
2025-07-03,5.08,5.59,5.08,5.59,4250014.47,39275697251
2025-07-04,5.6,5.74,5.52,5.6,4529145.33,39275697251
2025-07-07,5.5,5.79,5.49,5.58,2463078.1,39275697251
2025-07-08,5.55,5.95,5.53,5.78,3665165.9,39275697251
2025-07-09,5.75,5.82,5.65,5.69,2274246.96,39275697251
2025-07-10,5.67,5.76,5.51,5.58,2005171.32,39275697251
2025-07-11,5.57,5.58,5.39,5.5,1839462.11,39275697251
2025-07-14,5.51,5.55,5.42,5.44,1238426.57,39275697251
2025-07-15,5.45,5.6,5.4,5.47,2322143.38,39275697251
2025-07-16,5.29,5.47,5.29,5.36,1945350.4,39275697251
2025-07-17,5.33,5.57,5.3,5.48,2190584.97,39275697251
2025-07-18,5.47,5.65,5.45,5.5,2020531.6,39275697251
2025-07-21,5.52,5.66,5.43,5.48,1384268.25,39275697251
2025-07-22,5.45,5.55,5.35,5.37,1735870.73,39275697251
1 date open high low close volume market_cap
2 2025-06-18 4.65 4.86 4.6 4.83 2051281.76 39275697251
3 2025-06-19 4.79 4.98 4.75 4.78 1715941.43 39275697251
4 2025-06-20 4.77 4.81 4.63 4.65 1051952.17 39275697251
5 2025-06-23 4.6 4.75 4.57 4.7 934722.87 39275697251
6 2025-06-24 4.72 4.81 4.7 4.78 945714.0 39275697251
7 2025-06-25 4.8 4.85 4.73 4.81 1124786.7 39275697251
8 2025-06-26 4.86 5.05 4.83 4.94 2459293.2 39275697251
9 2025-06-27 4.94 5.42 4.85 5.27 4029657.48 39275697251
10 2025-06-30 5.25 5.43 5.25 5.34 2441261.2 39275697251
11 2025-07-01 5.31 5.38 5.24 5.3 1702111.13 39275697251
12 2025-07-02 5.26 5.28 5.05 5.08 1565861.38 39275697251
13 2025-07-03 5.08 5.59 5.08 5.59 4250014.47 39275697251
14 2025-07-04 5.6 5.74 5.52 5.6 4529145.33 39275697251
15 2025-07-07 5.5 5.79 5.49 5.58 2463078.1 39275697251
16 2025-07-08 5.55 5.95 5.53 5.78 3665165.9 39275697251
17 2025-07-09 5.75 5.82 5.65 5.69 2274246.96 39275697251
18 2025-07-10 5.67 5.76 5.51 5.58 2005171.32 39275697251
19 2025-07-11 5.57 5.58 5.39 5.5 1839462.11 39275697251
20 2025-07-14 5.51 5.55 5.42 5.44 1238426.57 39275697251
21 2025-07-15 5.45 5.6 5.4 5.47 2322143.38 39275697251
22 2025-07-16 5.29 5.47 5.29 5.36 1945350.4 39275697251
23 2025-07-17 5.33 5.57 5.3 5.48 2190584.97 39275697251
24 2025-07-18 5.47 5.65 5.45 5.5 2020531.6 39275697251
25 2025-07-21 5.52 5.66 5.43 5.48 1384268.25 39275697251
26 2025-07-22 5.45 5.55 5.35 5.37 1735870.73 39275697251
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-06-30,32.11,33.05,31.61,32.23,1421653.88,51810407315
2025-07-01,31.82,32.18,30.63,31.51,1313625.56,51810407315
2025-07-02,31.51,31.57,30.79,30.9,615208.98,51810407315
2025-07-03,31.16,31.21,30.49,30.96,810657.45,51810407315
2025-07-04,30.65,30.93,29.93,30.43,799767.87,51810407315
2025-07-07,30.43,30.67,30.09,30.22,482072.12,51810407315
2025-07-08,30.11,30.34,29.97,30.12,621125.66,51810407315
2025-07-09,30.19,30.86,29.73,29.83,1103713.14,51810407315
2025-07-10,29.58,30.08,29.46,29.7,591122.59,51810407315
2025-07-11,29.61,30.49,29.52,30.06,833099.95,51810407315
2025-07-14,30.07,30.42,29.69,29.86,504302.93,51810407315
2025-07-15,29.75,30.23,29.0,29.19,737141.8,51810407315
2025-07-16,29.18,29.45,29.01,29.21,367657.7,51810407315
2025-07-17,29.2,29.94,28.85,29.79,688212.84,51810407315
2025-07-18,30.09,31.56,29.9,31.0,1211206.13,51810407315
2025-07-21,30.98,31.37,30.36,31.07,772026.09,51810407315
2025-07-22,30.78,31.45,30.43,30.8,785708.74,51810407315
2025-07-23,30.56,30.57,29.89,29.92,703169.55,51810407315
2025-07-24,29.84,30.46,29.77,30.31,543627.27,51810407315
2025-07-25,30.36,31.13,30.36,30.45,619316.45,51810407315
2025-07-28,30.43,31.24,30.18,31.04,702169.14,51810407315
2025-07-29,30.79,31.15,30.33,30.69,542088.91,51810407315
2025-07-30,30.84,30.85,29.24,29.48,761650.21,51810407315
2025-07-31,29.34,30.01,28.94,29.12,470283.8,51810407315
2025-08-01,28.99,29.22,28.64,28.67,386407.75,51810407315
1 date open high low close volume market_cap
2 2025-06-30 32.11 33.05 31.61 32.23 1421653.88 51810407315
3 2025-07-01 31.82 32.18 30.63 31.51 1313625.56 51810407315
4 2025-07-02 31.51 31.57 30.79 30.9 615208.98 51810407315
5 2025-07-03 31.16 31.21 30.49 30.96 810657.45 51810407315
6 2025-07-04 30.65 30.93 29.93 30.43 799767.87 51810407315
7 2025-07-07 30.43 30.67 30.09 30.22 482072.12 51810407315
8 2025-07-08 30.11 30.34 29.97 30.12 621125.66 51810407315
9 2025-07-09 30.19 30.86 29.73 29.83 1103713.14 51810407315
10 2025-07-10 29.58 30.08 29.46 29.7 591122.59 51810407315
11 2025-07-11 29.61 30.49 29.52 30.06 833099.95 51810407315
12 2025-07-14 30.07 30.42 29.69 29.86 504302.93 51810407315
13 2025-07-15 29.75 30.23 29.0 29.19 737141.8 51810407315
14 2025-07-16 29.18 29.45 29.01 29.21 367657.7 51810407315
15 2025-07-17 29.2 29.94 28.85 29.79 688212.84 51810407315
16 2025-07-18 30.09 31.56 29.9 31.0 1211206.13 51810407315
17 2025-07-21 30.98 31.37 30.36 31.07 772026.09 51810407315
18 2025-07-22 30.78 31.45 30.43 30.8 785708.74 51810407315
19 2025-07-23 30.56 30.57 29.89 29.92 703169.55 51810407315
20 2025-07-24 29.84 30.46 29.77 30.31 543627.27 51810407315
21 2025-07-25 30.36 31.13 30.36 30.45 619316.45 51810407315
22 2025-07-28 30.43 31.24 30.18 31.04 702169.14 51810407315
23 2025-07-29 30.79 31.15 30.33 30.69 542088.91 51810407315
24 2025-07-30 30.84 30.85 29.24 29.48 761650.21 51810407315
25 2025-07-31 29.34 30.01 28.94 29.12 470283.8 51810407315
26 2025-08-01 28.99 29.22 28.64 28.67 386407.75 51810407315
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-06-27,19.98,22.09,19.61,22.09,276542.2,3567378360
2025-06-30,21.86,24.19,21.46,23.37,328614.33,3567378360
2025-07-01,22.39,22.77,21.46,21.46,246739.02,3567378360
2025-07-02,20.91,21.41,20.4,20.88,152920.45,3567378360
2025-07-03,20.81,22.49,20.72,22.05,226316.34,3567378360
2025-07-04,21.4,21.76,20.75,20.76,157410.92,3567378360
2025-07-07,20.47,21.17,20.32,21.0,89066.04,3567378360
2025-07-08,21.01,21.11,20.67,20.9,81440.83,3567378360
2025-07-09,20.91,21.39,20.42,20.51,94518.29,3567378360
2025-07-10,20.51,20.51,19.94,20.32,82854.2,3567378360
2025-07-11,20.42,20.6,20.12,20.41,63631.7,3567378360
2025-07-14,20.57,20.93,20.51,20.6,71670.49,3567378360
2025-07-15,20.45,20.78,20.13,20.47,70849.72,3567378360
2025-07-16,20.6,20.86,20.33,20.47,68310.79,3567378360
2025-07-17,20.26,20.6,19.92,20.51,62354.29,3567378360
2025-07-18,20.48,20.79,20.36,20.49,62896.87,3567378360
2025-07-21,20.36,20.97,20.12,20.52,68576.12,3567378360
2025-07-22,20.4,21.25,20.34,20.96,129095.1,3567378360
2025-07-23,20.84,20.88,20.09,20.17,96276.56,3567378360
2025-07-24,20.16,20.35,20.08,20.19,45888.62,3567378360
2025-07-25,20.21,20.21,19.97,20.08,38465.12,3567378360
2025-07-28,20.09,20.55,20.06,20.4,51218.04,3567378360
2025-07-29,20.34,20.54,19.79,19.93,61055.53,3567378360
2025-07-30,19.81,20.21,19.2,19.86,79996.39,3567378360
2025-07-31,19.66,19.99,19.37,19.48,43501.6,3567378360
1 date open high low close volume market_cap
2 2025-06-27 19.98 22.09 19.61 22.09 276542.2 3567378360
3 2025-06-30 21.86 24.19 21.46 23.37 328614.33 3567378360
4 2025-07-01 22.39 22.77 21.46 21.46 246739.02 3567378360
5 2025-07-02 20.91 21.41 20.4 20.88 152920.45 3567378360
6 2025-07-03 20.81 22.49 20.72 22.05 226316.34 3567378360
7 2025-07-04 21.4 21.76 20.75 20.76 157410.92 3567378360
8 2025-07-07 20.47 21.17 20.32 21.0 89066.04 3567378360
9 2025-07-08 21.01 21.11 20.67 20.9 81440.83 3567378360
10 2025-07-09 20.91 21.39 20.42 20.51 94518.29 3567378360
11 2025-07-10 20.51 20.51 19.94 20.32 82854.2 3567378360
12 2025-07-11 20.42 20.6 20.12 20.41 63631.7 3567378360
13 2025-07-14 20.57 20.93 20.51 20.6 71670.49 3567378360
14 2025-07-15 20.45 20.78 20.13 20.47 70849.72 3567378360
15 2025-07-16 20.6 20.86 20.33 20.47 68310.79 3567378360
16 2025-07-17 20.26 20.6 19.92 20.51 62354.29 3567378360
17 2025-07-18 20.48 20.79 20.36 20.49 62896.87 3567378360
18 2025-07-21 20.36 20.97 20.12 20.52 68576.12 3567378360
19 2025-07-22 20.4 21.25 20.34 20.96 129095.1 3567378360
20 2025-07-23 20.84 20.88 20.09 20.17 96276.56 3567378360
21 2025-07-24 20.16 20.35 20.08 20.19 45888.62 3567378360
22 2025-07-25 20.21 20.21 19.97 20.08 38465.12 3567378360
23 2025-07-28 20.09 20.55 20.06 20.4 51218.04 3567378360
24 2025-07-29 20.34 20.54 19.79 19.93 61055.53 3567378360
25 2025-07-30 19.81 20.21 19.2 19.86 79996.39 3567378360
26 2025-07-31 19.66 19.99 19.37 19.48 43501.6 3567378360
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-06-05,13.86,13.93,13.61,13.82,76839.0,9399394575
2025-06-06,13.83,14.01,13.66,13.7,69401.0,9399394575
2025-06-09,13.69,13.87,13.64,13.76,75300.24,9399394575
2025-06-10,13.7,13.74,12.99,13.18,174574.3,9399394575
2025-06-11,13.2,13.34,13.11,13.3,61388.02,9399394575
2025-06-12,13.26,13.35,13.14,13.21,46718.0,9399394575
2025-06-13,13.17,13.52,13.17,13.38,164443.0,9399394575
2025-06-16,13.48,13.75,13.17,13.68,140522.0,9399394575
2025-06-17,13.63,14.09,13.62,13.97,143405.8,9399394575
2025-06-18,13.98,14.72,13.89,14.72,275552.83,9399394575
2025-06-19,14.48,14.48,13.72,14.15,252934.0,9399394575
2025-06-20,14.15,14.16,13.75,13.8,127924.0,9399394575
2025-06-23,14.01,14.33,13.9,14.33,160493.0,9399394575
2025-06-24,14.19,14.87,13.84,14.54,252237.43,9399394575
2025-06-25,14.78,16.0,14.71,16.0,600588.02,9399394575
2025-06-26,16.0,17.6,15.98,16.63,846170.51,9399394575
2025-06-27,16.56,17.27,16.3,16.42,651687.06,9399394575
2025-06-30,16.58,17.57,16.58,17.54,612607.43,9399394575
2025-07-01,17.28,17.9,16.88,17.24,468426.25,9399394575
2025-07-02,17.18,17.18,16.42,16.61,337259.72,9399394575
2025-07-03,16.62,16.84,16.37,16.46,199869.31,9399394575
2025-07-04,16.37,16.45,16.04,16.1,180557.04,9399394575
2025-07-07,16.03,16.32,15.86,16.12,142471.31,9399394575
2025-07-08,15.98,16.16,15.91,16.07,122700.83,9399394575
2025-07-09,16.08,16.45,15.94,15.99,230184.09,9399394575
1 date open high low close volume market_cap
2 2025-06-05 13.86 13.93 13.61 13.82 76839.0 9399394575
3 2025-06-06 13.83 14.01 13.66 13.7 69401.0 9399394575
4 2025-06-09 13.69 13.87 13.64 13.76 75300.24 9399394575
5 2025-06-10 13.7 13.74 12.99 13.18 174574.3 9399394575
6 2025-06-11 13.2 13.34 13.11 13.3 61388.02 9399394575
7 2025-06-12 13.26 13.35 13.14 13.21 46718.0 9399394575
8 2025-06-13 13.17 13.52 13.17 13.38 164443.0 9399394575
9 2025-06-16 13.48 13.75 13.17 13.68 140522.0 9399394575
10 2025-06-17 13.63 14.09 13.62 13.97 143405.8 9399394575
11 2025-06-18 13.98 14.72 13.89 14.72 275552.83 9399394575
12 2025-06-19 14.48 14.48 13.72 14.15 252934.0 9399394575
13 2025-06-20 14.15 14.16 13.75 13.8 127924.0 9399394575
14 2025-06-23 14.01 14.33 13.9 14.33 160493.0 9399394575
15 2025-06-24 14.19 14.87 13.84 14.54 252237.43 9399394575
16 2025-06-25 14.78 16.0 14.71 16.0 600588.02 9399394575
17 2025-06-26 16.0 17.6 15.98 16.63 846170.51 9399394575
18 2025-06-27 16.56 17.27 16.3 16.42 651687.06 9399394575
19 2025-06-30 16.58 17.57 16.58 17.54 612607.43 9399394575
20 2025-07-01 17.28 17.9 16.88 17.24 468426.25 9399394575
21 2025-07-02 17.18 17.18 16.42 16.61 337259.72 9399394575
22 2025-07-03 16.62 16.84 16.37 16.46 199869.31 9399394575
23 2025-07-04 16.37 16.45 16.04 16.1 180557.04 9399394575
24 2025-07-07 16.03 16.32 15.86 16.12 142471.31 9399394575
25 2025-07-08 15.98 16.16 15.91 16.07 122700.83 9399394575
26 2025-07-09 16.08 16.45 15.94 15.99 230184.09 9399394575
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-06-27,20.43,20.56,20.04,20.27,54029.3,4707136785
2025-06-30,20.22,20.5,20.18,20.44,45101.98,4707136785
2025-07-01,20.43,20.56,20.05,20.3,46657.31,4707136785
2025-07-02,20.28,20.28,19.76,20.03,40227.67,4707136785
2025-07-03,20.1,20.18,19.73,19.89,29471.22,4707136785
2025-07-04,20.02,20.02,19.43,19.62,32150.52,4707136785
2025-07-07,19.61,19.85,19.4,19.78,24560.45,4707136785
2025-07-08,19.71,21.16,19.71,20.73,110928.39,4707136785
2025-07-09,21.04,21.22,20.32,20.47,80045.04,4707136785
2025-07-10,20.25,20.51,19.88,20.01,62948.74,4707136785
2025-07-11,19.93,21.34,19.73,21.0,126620.87,4707136785
2025-07-14,21.59,24.48,21.11,23.66,270019.12,4707136785
2025-07-15,23.57,24.31,23.06,23.93,231598.13,4707136785
2025-07-16,23.56,24.31,23.23,23.5,185860.26,4707136785
2025-07-17,23.37,24.42,23.03,23.5,162749.05,4707136785
2025-07-18,23.37,23.72,22.91,23.16,111343.75,4707136785
2025-07-21,23.44,24.61,23.14,24.02,176471.75,4707136785
2025-07-22,23.79,23.95,22.83,23.07,132565.5,4707136785
2025-07-23,22.9,23.07,22.38,22.71,71180.1,4707136785
2025-07-24,22.55,23.17,22.52,22.71,55999.01,4707136785
2025-07-25,22.63,22.87,22.42,22.61,54779.45,4707136785
2025-07-28,22.97,25.66,22.97,24.61,247298.15,4707136785
2025-07-29,24.08,24.45,23.8,24.14,144127.75,4707136785
2025-07-30,23.96,24.23,23.15,23.27,110016.38,4707136785
2025-07-31,23.07,23.58,22.74,22.87,84262.28,4707136785
1 date open high low close volume market_cap
2 2025-06-27 20.43 20.56 20.04 20.27 54029.3 4707136785
3 2025-06-30 20.22 20.5 20.18 20.44 45101.98 4707136785
4 2025-07-01 20.43 20.56 20.05 20.3 46657.31 4707136785
5 2025-07-02 20.28 20.28 19.76 20.03 40227.67 4707136785
6 2025-07-03 20.1 20.18 19.73 19.89 29471.22 4707136785
7 2025-07-04 20.02 20.02 19.43 19.62 32150.52 4707136785
8 2025-07-07 19.61 19.85 19.4 19.78 24560.45 4707136785
9 2025-07-08 19.71 21.16 19.71 20.73 110928.39 4707136785
10 2025-07-09 21.04 21.22 20.32 20.47 80045.04 4707136785
11 2025-07-10 20.25 20.51 19.88 20.01 62948.74 4707136785
12 2025-07-11 19.93 21.34 19.73 21.0 126620.87 4707136785
13 2025-07-14 21.59 24.48 21.11 23.66 270019.12 4707136785
14 2025-07-15 23.57 24.31 23.06 23.93 231598.13 4707136785
15 2025-07-16 23.56 24.31 23.23 23.5 185860.26 4707136785
16 2025-07-17 23.37 24.42 23.03 23.5 162749.05 4707136785
17 2025-07-18 23.37 23.72 22.91 23.16 111343.75 4707136785
18 2025-07-21 23.44 24.61 23.14 24.02 176471.75 4707136785
19 2025-07-22 23.79 23.95 22.83 23.07 132565.5 4707136785
20 2025-07-23 22.9 23.07 22.38 22.71 71180.1 4707136785
21 2025-07-24 22.55 23.17 22.52 22.71 55999.01 4707136785
22 2025-07-25 22.63 22.87 22.42 22.61 54779.45 4707136785
23 2025-07-28 22.97 25.66 22.97 24.61 247298.15 4707136785
24 2025-07-29 24.08 24.45 23.8 24.14 144127.75 4707136785
25 2025-07-30 23.96 24.23 23.15 23.27 110016.38 4707136785
26 2025-07-31 23.07 23.58 22.74 22.87 84262.28 4707136785
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-06-06,17.64,19.41,17.16,19.41,295488.65,4910064388
2025-06-09,20.53,21.35,20.53,21.35,162351.45,4910064388
2025-06-10,23.48,23.48,22.95,23.48,96161.81,4910064388
2025-06-11,25.5,25.83,24.22,25.83,538547.27,4910064388
2025-06-12,27.48,28.07,25.7,26.81,487592.83,4910064388
2025-06-13,25.98,26.92,25.23,26.07,302545.06,4910064388
2025-06-16,25.81,28.68,25.41,28.68,285190.35,4910064388
2025-06-17,30.1,31.49,28.19,28.7,386383.92,4910064388
2025-06-18,28.11,28.57,26.72,27.7,314113.19,4910064388
2025-06-19,28.59,30.13,27.97,28.73,229020.92,4910064388
2025-06-20,26.78,31.6,26.78,31.6,174055.39,4910064388
2025-06-23,31.05,31.95,28.44,31.67,234024.79,4910064388
2025-06-24,31.07,34.13,31.07,33.05,233411.76,4910064388
2025-06-25,31.9,34.7,31.85,32.5,242343.26,4910064388
2025-06-26,30.83,32.1,29.28,30.55,193713.48,4910064388
2025-06-27,30.57,33.6,30.26,33.6,131012.98,4910064388
2025-06-30,33.6,36.97,33.6,36.71,195320.19,4910064388
2025-07-01,35.84,40.38,35.83,40.38,161306.24,4910064388
2025-07-02,40.35,44.42,39.75,44.42,212138.21,4910064388
2025-07-03,40.56,48.08,40.56,44.44,176844.52,4910064388
2025-07-04,43.53,44.12,40.49,40.96,138196.86,4910064388
2025-07-07,42.76,42.76,39.49,41.12,105590.86,4910064388
2025-07-08,41.36,41.5,38.07,39.52,105213.28,4910064388
2025-07-09,39.31,40.63,37.92,39.33,97899.1,4910064388
2025-07-10,39.46,39.56,36.96,37.43,79650.61,4910064388
1 date open high low close volume market_cap
2 2025-06-06 17.64 19.41 17.16 19.41 295488.65 4910064388
3 2025-06-09 20.53 21.35 20.53 21.35 162351.45 4910064388
4 2025-06-10 23.48 23.48 22.95 23.48 96161.81 4910064388
5 2025-06-11 25.5 25.83 24.22 25.83 538547.27 4910064388
6 2025-06-12 27.48 28.07 25.7 26.81 487592.83 4910064388
7 2025-06-13 25.98 26.92 25.23 26.07 302545.06 4910064388
8 2025-06-16 25.81 28.68 25.41 28.68 285190.35 4910064388
9 2025-06-17 30.1 31.49 28.19 28.7 386383.92 4910064388
10 2025-06-18 28.11 28.57 26.72 27.7 314113.19 4910064388
11 2025-06-19 28.59 30.13 27.97 28.73 229020.92 4910064388
12 2025-06-20 26.78 31.6 26.78 31.6 174055.39 4910064388
13 2025-06-23 31.05 31.95 28.44 31.67 234024.79 4910064388
14 2025-06-24 31.07 34.13 31.07 33.05 233411.76 4910064388
15 2025-06-25 31.9 34.7 31.85 32.5 242343.26 4910064388
16 2025-06-26 30.83 32.1 29.28 30.55 193713.48 4910064388
17 2025-06-27 30.57 33.6 30.26 33.6 131012.98 4910064388
18 2025-06-30 33.6 36.97 33.6 36.71 195320.19 4910064388
19 2025-07-01 35.84 40.38 35.83 40.38 161306.24 4910064388
20 2025-07-02 40.35 44.42 39.75 44.42 212138.21 4910064388
21 2025-07-03 40.56 48.08 40.56 44.44 176844.52 4910064388
22 2025-07-04 43.53 44.12 40.49 40.96 138196.86 4910064388
23 2025-07-07 42.76 42.76 39.49 41.12 105590.86 4910064388
24 2025-07-08 41.36 41.5 38.07 39.52 105213.28 4910064388
25 2025-07-09 39.31 40.63 37.92 39.33 97899.1 4910064388
26 2025-07-10 39.46 39.56 36.96 37.43 79650.61 4910064388
@@ -0,0 +1,26 @@
date,open,high,low,close,volume,market_cap
2025-09-30,7.57,7.76,7.57,7.74,204915.56,22650295811
2025-10-09,7.75,7.8,7.68,7.8,196793.96,22650295811
2025-10-10,7.79,7.82,7.73,7.75,163527.18,22650295811
2025-10-13,7.6,7.8,7.47,7.8,208009.58,22650295811
2025-10-14,7.82,7.9,7.73,7.78,203765.38,22650295811
2025-10-15,7.77,7.78,7.67,7.75,158196.56,22650295811
2025-10-16,7.74,7.76,7.61,7.63,151268.43,22650295811
2025-10-17,7.62,7.72,7.47,7.48,162246.05,22650295811
2025-10-20,7.55,7.61,7.51,7.58,122212.11,22650295811
2025-10-21,7.58,7.67,7.56,7.64,121825.06,22650295811
2025-10-22,7.64,7.86,7.58,7.82,322717.34,22650295811
2025-10-23,7.8,7.82,7.67,7.81,170156.0,22650295811
2025-10-24,8.1,8.3,7.95,7.98,615299.42,22650295811
2025-10-27,8.0,8.18,7.94,8.04,433223.34,22650295811
2025-10-28,7.99,8.84,7.97,8.84,1610159.98,22650295811
2025-10-29,8.6,9.0,8.41,8.75,1722050.97,22650295811
2025-10-30,8.7,8.82,8.51,8.6,1035934.51,22650295811
2025-10-31,8.57,8.62,8.34,8.37,686044.09,22650295811
2025-11-03,8.37,8.61,8.33,8.6,748009.31,22650295811
2025-11-04,8.52,9.26,8.5,8.98,1365750.29,22650295811
2025-11-05,8.76,8.91,8.67,8.81,823459.67,22650295811
2025-11-06,8.77,8.8,8.6,8.65,553188.01,22650295811
2025-11-07,8.67,8.75,8.56,8.67,592496.69,22650295811
2025-11-10,8.74,8.79,8.51,8.53,513705.42,22650295811
2025-11-11,8.48,8.54,8.39,8.47,440314.81,22650295811
1 date open high low close volume market_cap
2 2025-09-30 7.57 7.76 7.57 7.74 204915.56 22650295811
3 2025-10-09 7.75 7.8 7.68 7.8 196793.96 22650295811
4 2025-10-10 7.79 7.82 7.73 7.75 163527.18 22650295811
5 2025-10-13 7.6 7.8 7.47 7.8 208009.58 22650295811
6 2025-10-14 7.82 7.9 7.73 7.78 203765.38 22650295811
7 2025-10-15 7.77 7.78 7.67 7.75 158196.56 22650295811
8 2025-10-16 7.74 7.76 7.61 7.63 151268.43 22650295811
9 2025-10-17 7.62 7.72 7.47 7.48 162246.05 22650295811
10 2025-10-20 7.55 7.61 7.51 7.58 122212.11 22650295811
11 2025-10-21 7.58 7.67 7.56 7.64 121825.06 22650295811
12 2025-10-22 7.64 7.86 7.58 7.82 322717.34 22650295811
13 2025-10-23 7.8 7.82 7.67 7.81 170156.0 22650295811
14 2025-10-24 8.1 8.3 7.95 7.98 615299.42 22650295811
15 2025-10-27 8.0 8.18 7.94 8.04 433223.34 22650295811
16 2025-10-28 7.99 8.84 7.97 8.84 1610159.98 22650295811
17 2025-10-29 8.6 9.0 8.41 8.75 1722050.97 22650295811
18 2025-10-30 8.7 8.82 8.51 8.6 1035934.51 22650295811
19 2025-10-31 8.57 8.62 8.34 8.37 686044.09 22650295811
20 2025-11-03 8.37 8.61 8.33 8.6 748009.31 22650295811
21 2025-11-04 8.52 9.26 8.5 8.98 1365750.29 22650295811
22 2025-11-05 8.76 8.91 8.67 8.81 823459.67 22650295811
23 2025-11-06 8.77 8.8 8.6 8.65 553188.01 22650295811
24 2025-11-07 8.67 8.75 8.56 8.67 592496.69 22650295811
25 2025-11-10 8.74 8.79 8.51 8.53 513705.42 22650295811
26 2025-11-11 8.48 8.54 8.39 8.47 440314.81 22650295811
@@ -0,0 +1,43 @@
{
"algorithm": {
"version": "zhixing_b1_pattern_fastdtw_v1",
"radius": 1,
"distance": "scalar_euclidean",
"lookback_days": 25,
"threshold": 60.0,
"weights": [0.10, 0.20, 0.25, 0.45]
},
"self_match": {
"status": "matched",
"value": 95.0,
"case_id": "case_001",
"breakdown": {
"trend_structure": 50.0,
"kdj_state": 100.0,
"volume_pattern": 100.0,
"price_shape": 100.0
}
},
"time_warped": {
"status": "matched",
"value": 78.38,
"case_id": "case_001",
"breakdown": {
"trend_structure": 44.51,
"kdj_state": 77.15,
"volume_pattern": 65.0,
"price_shape": 93.89
}
},
"below_threshold": {
"status": "below_threshold",
"value": 46.27,
"case_id": "case_010",
"breakdown": {
"trend_structure": 28.33,
"kdj_state": 91.79,
"volume_pattern": 27.5,
"price_shape": 40.45
}
}
}
@@ -26,7 +26,8 @@ def test_postgres_migration_creates_market_data_contract(
engine: Engine = create_engine(sqlalchemy_url)
command.upgrade(config, "head")
try:
tables = set(inspect(engine).get_table_names())
inspector = inspect(engine)
tables = set(inspector.get_table_names())
assert {
"market_stock",
"market_daily_bar",
@@ -39,6 +40,25 @@ def test_postgres_migration_creates_market_data_contract(
"selection_run_item",
"selection_signal",
} <= tables
item_columns = {column["name"] for column in inspector.get_columns("selection_run_item")}
assert {
"score_status",
"score_value",
"score_threshold",
"score_version",
"match_case_id",
"match_case_name",
"match_case_breakout_date",
"match_breakdown",
"score_reason",
} <= item_columns
constraint_names = {
constraint["name"]
for constraint in inspector.get_check_constraints("selection_run_item")
}
assert "ck_selection_run_item_breakdown_range" in constraint_names
index_names = {index["name"] for index in inspector.get_indexes("selection_run_item")}
assert "ix_selection_run_item_score" in index_names
finally:
engine.dispose()
get_settings.cache_clear()
+100
View File
@@ -1,5 +1,6 @@
"""HTTP contracts for triggering and querying persisted selection runs."""
from dataclasses import replace
from datetime import date
from decimal import Decimal
@@ -11,6 +12,12 @@ from zhixing_server.bootstrap.app import create_app
from zhixing_server.bootstrap.config import Settings
from zhixing_server.modules.selection.application.run import PreparedSelectionRun
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.pattern_scoring import (
PATTERN_SCORING_VERSION,
ZHIXING_B1_PATTERN_CASES,
PatternScore,
PatternScoreBreakdown,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
@@ -130,6 +137,14 @@ def _run(run_id: str, status: str) -> SelectionRun:
name="平安银行",
status="selected",
signal_count=2,
pattern_score=PatternScore(
status="matched",
value=86.4,
threshold=60.0,
version=PATTERN_SCORING_VERSION,
case=ZHIXING_B1_PATTERN_CASES[0],
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
),
signals=(original_signal, pullback_signal),
),
),
@@ -238,6 +253,24 @@ def test_query_returns_persisted_signal_details() -> None:
assert "signals" not in body
assert len(body["stocks"]) == 1
assert body["stocks"][0]["ts_code"] == "000001.SZ"
assert body["stocks"][0]["score"] == {
"status": "matched",
"value": 86.4,
"threshold": 60.0,
"version": PATTERN_SCORING_VERSION,
"case": {
"id": "case_001",
"name": "华纳药厂",
"breakout_date": "2025-05-12",
},
"breakdown": {
"trend_structure": 71.2,
"kdj_state": 83.0,
"volume_pattern": 88.0,
"price_shape": 90.1,
},
"reason": None,
}
assert [signal["category"] for signal in body["stocks"][0]["signals"]] == [
"zhixing_b1_original_b1",
"zhixing_b1_pullback_white",
@@ -248,6 +281,59 @@ def test_query_returns_persisted_signal_details() -> None:
]
@pytest.mark.parametrize(
("pattern_score", "expected_score"),
[
(
PatternScore(
status="below_threshold",
value=42.5,
threshold=60.0,
version=PATTERN_SCORING_VERSION,
case=ZHIXING_B1_PATTERN_CASES[0],
breakdown=PatternScoreBreakdown(40.0, 42.0, 43.0, 44.0),
),
{
"status": "below_threshold",
"value": None,
"threshold": 60.0,
"version": PATTERN_SCORING_VERSION,
"case": None,
"breakdown": None,
"reason": "未匹配到评分阈值以上案例",
},
),
(
PatternScore.failed("FastDTW unavailable"),
{
"status": "failed",
"value": None,
"threshold": None,
"version": None,
"case": None,
"breakdown": None,
"reason": "FastDTW unavailable",
},
),
(PatternScore(), None),
],
)
def test_query_preserves_signals_for_every_pattern_score_state(
pattern_score: PatternScore,
expected_score: dict[str, object] | None,
) -> None:
run = _run("run-http", "success")
run = replace(run, items=(replace(run.items[0], pattern_score=pattern_score),))
response = _client(FakeSelectionService(run)).get("/api/v1/selection/results")
assert response.status_code == 200
stock = response.json()["stocks"][0]
assert stock["score"] == expected_score
assert len(stock["signals"]) == 2
assert response.json()["failures"] == []
def test_query_forwards_pagination_and_filters() -> None:
service = FakeSelectionService(_run("run-http", "success"))
@@ -259,6 +345,7 @@ def test_query_forwards_pagination_and_filters() -> None:
"page_size": 5,
"search": " 平安银行 ",
"category": "original",
"sort": "score_desc",
},
)
@@ -268,6 +355,7 @@ def test_query_forwards_pagination_and_filters() -> None:
page_size=5,
search="平安银行",
category="original",
sort="score_desc",
)
assert response.json()["page"] == 2
assert response.json()["page_size"] == 5
@@ -282,6 +370,18 @@ def test_query_rejects_invalid_page_size() -> None:
assert response.status_code == 422
def test_query_forwards_score_ascending_sort() -> None:
service = FakeSelectionService(_run("run-http", "success"))
response = _client(service).get(
"/api/v1/selection/results",
params={"strategy": "zhixing_b1", "sort": "score_asc"},
)
assert response.status_code == 200
assert service.last_query == SelectionResultQuery(sort="score_asc")
def test_run_polling_returns_the_persisted_terminal_result() -> None:
response = _client(FakeSelectionService(_run("run-http", "success"))).get(
"/api/v1/selection/runs/run-http"
@@ -0,0 +1,182 @@
"""Golden and invariant tests for versioned B1 FastDTW scoring."""
from __future__ import annotations
import json
from datetime import date, timedelta
from pathlib import Path
from typing import cast
import pandas as pd
import pytest
from zhixing_server.modules.selection.domain.models import SelectionBar, StockHistory
from zhixing_server.modules.selection.domain.pattern_scoring import (
PATTERN_FASTDTW_RADIUS,
PATTERN_SCORING_VERSION,
ZHIXING_B1_PATTERN_CASES,
PatternCase,
PatternCaseLibraryError,
PatternFeatures,
PatternScore,
PatternScoreBreakdown,
PatternScoringError,
ZhixingB1PatternScorer,
build_pattern_case,
)
FIXTURES = Path(__file__).parents[2] / "fixtures" / "selection" / "zhixing_b1" / "pattern_scoring"
def _history(case_id: str, ts_code: str, name: str) -> StockHistory:
frame = pd.read_csv(FIXTURES / f"{case_id}.csv")
bars = tuple(
SelectionBar(
trade_date=date.fromisoformat(str(row.date)),
open=float(str(row.open)),
high=float(str(row.high)),
low=float(str(row.low)),
close=float(str(row.close)),
volume=float(str(row.volume)),
)
for row in frame.itertuples(index=False)
)
return StockHistory(ts_code=ts_code, name=name, bars=bars)
def _cases() -> tuple[PatternCase, ...]:
return tuple(
build_pattern_case(
definition,
_history(definition.id, definition.ts_code, definition.name),
)
for definition in ZHIXING_B1_PATTERN_CASES
)
def _golden(name: str) -> dict[str, object]:
payload = cast(dict[str, object], json.loads((FIXTURES / "golden.json").read_text()))
return cast(dict[str, object], payload[name])
def _assert_golden(score: PatternScore, expected: dict[str, object]) -> None:
assert score.status == expected["status"]
assert score.value == expected["value"]
assert score.case is not None
assert score.case.id == expected["case_id"]
assert score.breakdown is not None
assert score.breakdown.as_dict() == expected["breakdown"]
def test_fastdtw_v1_self_match_golden_is_finite_and_deterministic() -> None:
cases = _cases()
scorer = ZhixingB1PatternScorer()
first = scorer.score(cases[0].history, cases)
second = scorer.score(cases[0].history, cases)
assert PATTERN_SCORING_VERSION == "zhixing_b1_pattern_fastdtw_v1"
assert PATTERN_FASTDTW_RADIUS == 1
assert first == second
_assert_golden(first, _golden("self_match"))
assert cases[0].features.trend_structure["short_vs_bullbear"] is None
def test_fastdtw_v1_time_warped_curve_golden() -> None:
cases = _cases()
base = cases[0].history.bars
delayed = base[:1] * 3 + base[:-3]
bars = tuple(
SelectionBar(
trade_date=base[index].trade_date,
open=delayed[index].open,
high=delayed[index].high,
low=delayed[index].low,
close=delayed[index].close,
volume=delayed[index].volume,
)
for index in range(25)
)
result = ZhixingB1PatternScorer().score(
StockHistory(ts_code="TEST.SZ", name="time warped", bars=bars),
cases,
)
_assert_golden(result, _golden("time_warped"))
def test_below_threshold_golden_remains_a_successful_computation() -> None:
bars = tuple(
SelectionBar(
trade_date=date(2026, 1, 1) + timedelta(days=index),
open=100.0 if index % 2 == 0 else 1.0,
high=110.0,
low=0.9,
close=1.0 if index % 2 == 0 else 100.0,
volume=1.0 if index < 13 else 1_000_000.0,
)
for index in range(25)
)
result = ZhixingB1PatternScorer().score(
StockHistory(ts_code="TEST.SZ", name="below", bars=bars),
_cases(),
)
_assert_golden(result, _golden("below_threshold"))
def test_case_library_rejects_partial_or_short_input() -> None:
cases = _cases()
with pytest.raises(PatternScoringError, match="incomplete or out of order"):
ZhixingB1PatternScorer().score(cases[0].history, cases[:-1])
definition = ZHIXING_B1_PATTERN_CASES[0]
short = _history(definition.id, definition.ts_code, definition.name)
with pytest.raises(PatternCaseLibraryError, match="requires 25 complete rows"):
build_pattern_case(
definition,
StockHistory(short.ts_code, short.name, short.bars[:-1]),
)
def test_fastdtw_failure_is_not_replaced_by_simple_dtw(monkeypatch: pytest.MonkeyPatch) -> None:
import zhixing_server.modules.selection.domain.pattern_scoring as scoring
def fail(*_args: object, **_kwargs: object) -> tuple[float, list[tuple[int, int]]]:
raise RuntimeError("fastdtw unavailable")
monkeypatch.setattr(scoring, "_fastdtw", lambda: fail)
with pytest.raises(RuntimeError, match="fastdtw unavailable"):
ZhixingB1PatternScorer().score(_cases()[0].history, _cases())
def test_failed_score_requires_a_safe_reason() -> None:
with pytest.raises(ValueError, match="requires a safe reason"):
PatternScore(status="failed")
assert PatternScore.failed(" ").reason == "pattern scoring failed"
def test_threshold_is_inclusive_and_equal_scores_keep_first_case(
monkeypatch: pytest.MonkeyPatch,
) -> None:
import zhixing_server.modules.selection.domain.pattern_scoring as scoring
tied = PatternScoreBreakdown(60.0, 60.0, 60.0, 60.0)
def tied_match(
_candidate: PatternFeatures,
_case: PatternFeatures,
) -> PatternScoreBreakdown:
return tied
monkeypatch.setattr(scoring, "_match", tied_match)
result = ZhixingB1PatternScorer().score(_cases()[0].history, _cases())
assert result.status == "matched"
assert result.value == 60.0
assert result.case == ZHIXING_B1_PATTERN_CASES[0]
@@ -2,17 +2,19 @@
from collections.abc import Generator
from contextlib import contextmanager
from datetime import date
from datetime import date, timedelta
from decimal import Decimal
from typing import cast
import psycopg
import pytest
from zhixing_server.modules.selection.domain.pattern_scoring import ZHIXING_B1_PATTERN_CASES
from zhixing_server.modules.selection.domain.runs import SelectionStock
from zhixing_server.modules.selection.infrastructure.postgres_pool import SelectionPostgresPool
from zhixing_server.modules.selection.infrastructure.postgres_reader import (
PostgresMarketDataReader,
PostgresPatternCaseLibraryLoader,
SelectionMarketDataNotReady,
)
@@ -237,3 +239,34 @@ def test_reader_rejects_date_without_eligible_market_batch(monkeypatch: pytest.M
"zhixing_b1",
date(2026, 8, 8),
)
def test_pattern_case_loader_reads_one_complete_exclusive_qfq_library() -> None:
rows: list[tuple[object, ...]] = []
for definition in ZHIXING_B1_PATTERN_CASES:
for offset in range(definition.lookback_days, 0, -1):
rows.append(
(
definition.id,
definition.ts_code,
definition.breakout_date - timedelta(days=offset),
"10",
"11",
"9",
str(10 + offset / 100),
str(1000 + offset),
)
)
connection = FakeConnection(rows)
pool = Pool(connection)
owner = SelectionPostgresPool("postgresql://test", max_connections=2, pool=pool)
cases = PostgresPatternCaseLibraryLoader("postgresql://test", pool=owner).load()
assert tuple(case.definition for case in cases) == ZHIXING_B1_PATTERN_CASES
assert all(len(case.history.bars) == 25 for case in cases)
assert all(case.history.bars[-1].trade_date < case.definition.breakout_date for case in cases)
assert "bar.trade_date < definition.breakout_date" in cast(str, connection.query)
assert "bar.source_adj = 'qfq'" in cast(str, connection.query)
assert connection.parameters is not None
assert connection.parameters[0] == [definition.id for definition in ZHIXING_B1_PATTERN_CASES]
@@ -8,6 +8,12 @@ import pytest
from psycopg.types.json import Jsonb
from zhixing_server.modules.selection.domain.models import SelectionSignal, ZhixingB1Category
from zhixing_server.modules.selection.domain.pattern_scoring import (
PATTERN_SCORING_VERSION,
ZHIXING_B1_PATTERN_CASES,
PatternScore,
PatternScoreBreakdown,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionRerunRequired,
@@ -222,6 +228,14 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
name="平安银行",
status="selected",
signal_count=2,
pattern_score=PatternScore(
status="matched",
value=86.4,
threshold=60.0,
version=PATTERN_SCORING_VERSION,
case=ZHIXING_B1_PATTERN_CASES[0],
breakdown=PatternScoreBreakdown(71.2, 83.0, 88.0, 90.1),
),
signals=(first, second),
),
SelectionRunItem(
@@ -238,6 +252,28 @@ def test_record_items_uses_one_delete_and_two_batch_upserts(
assert delete_parameters == ("run-1", ["000001.SZ", "600000.SH"])
assert len(connection.executemany_calls) == 2
assert "INSERT INTO selection_run_item" in connection.executemany_calls[0][0]
item_parameters = connection.executemany_calls[0][1]
assert item_parameters[0][6:13] == (
"matched",
86.4,
60.0,
PATTERN_SCORING_VERSION,
"case_001",
"华纳药厂",
date(2025, 5, 12),
)
assert isinstance(item_parameters[0][13], Jsonb)
assert item_parameters[1][6:] == (
"not_executed",
None,
None,
None,
None,
None,
None,
None,
None,
)
assert "INSERT INTO selection_signal" in connection.executemany_calls[1][0]
signal_parameters = connection.executemany_calls[1][1]
assert len(signal_parameters) == 2
@@ -281,11 +317,35 @@ class LoadConnection:
None,
)
)
if "FROM selection_run_item" in query:
return LoadResult(rows=[("000001.SZ", "平安银行", "selected", 2, None)])
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query:
if "FROM selection_run_item\n" in query:
return LoadResult(
rows=[
(
"000001.SZ",
"平安银行",
"selected",
2,
None,
"matched",
Decimal("86.40"),
Decimal("60.00"),
PATTERN_SCORING_VERSION,
"case_001",
"华纳药厂",
date(2025, 5, 12),
{
"trend_structure": 71.2,
"kdj_state": 83.0,
"volume_pattern": 88.0,
"price_shape": 90.1,
},
None,
)
]
)
if "SELECT COUNT(*) FROM selection_run_item AS item" in query:
return LoadResult(row=(2,))
if "SELECT DISTINCT ts_code" in query:
if "SELECT item.ts_code" in query:
return LoadResult(rows=[("000001.SZ",)])
return LoadResult(
rows=[
@@ -315,7 +375,7 @@ class EmptyStockPageConnection(LoadConnection):
"""Return a non-zero filtered total with no stocks on the requested page."""
def execute(self, query: str, parameters: tuple[object, ...]) -> "LoadResult":
if "SELECT DISTINCT ts_code" in query:
if "SELECT item.ts_code" in query:
self.statements.append((query, parameters))
return LoadResult(rows=[])
return super().execute(query, parameters)
@@ -355,6 +415,7 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches(
page_size=1,
search="100%",
category="pullback",
sort="score_desc",
),
)
@@ -364,19 +425,21 @@ def test_get_run_pages_stocks_and_loads_all_signals_for_category_matches(
ZHIXING_B1_SIGNAL_ORDER[-1],
]
assert run.stocks_total == 2
assert run.items[0].pattern_score.status == "matched"
assert run.items[0].pattern_score.value == 86.4
count_query, count_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "COUNT(DISTINCT ts_code) FROM selection_signal" in query
if "SELECT COUNT(*) FROM selection_run_item AS item" in query
)
assert "name ILIKE %s ESCAPE" in count_query
assert count_parameters == ("run-1", "%100\\%%", "%100\\%%", "zhixing_b1_pullback_%")
stock_page_query, page_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "SELECT DISTINCT ts_code" in query
if "SELECT item.ts_code" in query
)
assert "ORDER BY ts_code" in stock_page_query
assert "ORDER BY item.score_value DESC NULLS LAST, item.ts_code ASC" in stock_page_query
assert page_parameters[-2:] == (1, 0)
signal_query, signal_parameters = next(
(query, parameters)
@@ -409,8 +472,30 @@ def test_get_run_does_not_load_signals_for_an_empty_stock_page(
stock_page_query, stock_page_parameters = next(
(query, parameters)
for query, parameters in connection.statements
if "SELECT DISTINCT ts_code" in query
if "SELECT item.ts_code" in query
)
assert "ORDER BY ts_code" in stock_page_query
assert "ORDER BY item.ts_code ASC" in stock_page_query
assert stock_page_parameters[-2:] == (1, 2)
assert not any("ts_code = ANY(%s)" in query for query, _ in connection.statements)
def test_get_run_sorts_scores_ascending_with_nulls_last_and_code_tiebreak(
monkeypatch: pytest.MonkeyPatch,
) -> None:
connection = LoadConnection()
def connect(database_url: str) -> LoadConnection:
assert database_url == "postgresql://test"
return connection
monkeypatch.setattr(psycopg, "connect", connect)
run = PostgresSelectionRunRepository("postgresql://test").get_run(
"run-1",
query=SelectionResultQuery(sort="score_asc"),
)
assert run is not None
stock_page_query = next(
query for query, _ in connection.statements if "SELECT item.ts_code" in query
)
assert "ORDER BY item.score_value ASC NULLS LAST, item.ts_code ASC" in stock_page_query
@@ -2,6 +2,7 @@
import threading
import time
from collections.abc import Sequence
from datetime import date
from decimal import Decimal
from typing import Literal
@@ -15,6 +16,14 @@ from zhixing_server.modules.selection.domain.models import (
SelectionSignal,
StockHistory,
)
from zhixing_server.modules.selection.domain.pattern_scoring import (
PATTERN_SCORE_THRESHOLD,
PATTERN_SCORING_VERSION,
ZHIXING_B1_PATTERN_CASES,
PatternCase,
PatternScore,
PatternScoreBreakdown,
)
from zhixing_server.modules.selection.domain.runs import (
SelectionExecutionSource,
SelectionResultQuery,
@@ -198,6 +207,37 @@ class ConcurrentHistoryEvaluator:
return SelectionEvaluation(history.ts_code, target_trade_date, "no_signal")
class FakePatternCaseLoader:
def __init__(self, *, error: Exception | None = None) -> None:
self.calls = 0
self.error = error
def load(self) -> tuple[PatternCase, ...]:
self.calls += 1
if self.error is not None:
raise self.error
return ()
class FakePatternScorer:
def __init__(self, *, error: Exception | None = None) -> None:
self.calls: list[str] = []
self.error = error
def score(self, history: StockHistory, cases: Sequence[PatternCase]) -> PatternScore:
self.calls.append(history.ts_code)
if self.error is not None:
raise self.error
return PatternScore(
status="matched",
value=88.0,
threshold=PATTERN_SCORE_THRESHOLD,
version=PATTERN_SCORING_VERSION,
case=ZHIXING_B1_PATTERN_CASES[0],
breakdown=PatternScoreBreakdown(80.0, 85.0, 90.0, 88.0),
)
def _source() -> SelectionExecutionSource:
return SelectionExecutionSource(
market_sync_batch_id="market-run-1",
@@ -364,3 +404,149 @@ def test_execute_marks_batch_write_failure_as_failed() -> None:
assert store.finished[0:2] == ("run-1", "failed")
assert store.finished[2]["error_type"] == "batch_error"
assert store.finished[2]["failed_count"] == 1
def test_execute_loads_cases_once_and_scores_only_selected_stocks() -> None:
source = _source()
reader = BatchReader(source)
store = FakeStore()
loader = FakePatternCaseLoader()
scorer = FakePatternScorer()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
),
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
}
)
service = RunZhixingB1(
reader,
store,
evaluator,
loader,
scorer,
pattern_scoring_enabled=True,
batch_size=1,
)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert loader.calls == 1
assert scorer.calls == ["000001.SZ"]
assert [item.pattern_score.status for item in store.items] == ["matched", "not_executed"]
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "success")
assert store.finished[2]["failed_count"] == 0
def test_execute_isolates_pattern_scoring_failure_from_selection_status() -> None:
source = _source()
store = FakeStore()
loader = FakePatternCaseLoader()
scorer = FakePatternScorer(error=RuntimeError("FastDTW unavailable"))
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
),
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
}
)
service = RunZhixingB1(
BatchReader(source),
store,
evaluator,
loader,
scorer,
pattern_scoring_enabled=True,
)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert store.items[0].status == "selected"
assert store.items[0].pattern_score == PatternScore.failed("FastDTW unavailable")
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "success")
assert store.finished[2]["failed_count"] == 0
def test_execute_skips_pattern_dependencies_when_feature_flag_is_disabled() -> None:
source = _source()
store = FakeStore()
loader = FakePatternCaseLoader(error=AssertionError("loader must not run"))
scorer = FakePatternScorer(error=AssertionError("scorer must not run"))
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
),
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
}
)
service = RunZhixingB1(
BatchReader(source),
store,
evaluator,
loader,
scorer,
pattern_scoring_enabled=False,
)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert loader.calls == 0
assert scorer.calls == []
assert [item.pattern_score.status for item in store.items] == [
"not_executed",
"not_executed",
]
assert store.items[0].signal_count == 1
assert store.finished is not None
assert store.finished[2]["failed_count"] == 0
def test_execute_marks_scores_failed_when_case_library_is_unavailable() -> None:
source = _source()
store = FakeStore()
loader = FakePatternCaseLoader(error=RuntimeError("case_011 requires 25 qfq rows"))
scorer = FakePatternScorer()
evaluator = FakeEvaluator(
{
"000001.SZ": SelectionEvaluation(
"000001.SZ",
TARGET,
"selected",
signals=(_signal("000001.SZ", "zhixing_b1_original_b1"),),
),
"600000.SH": SelectionEvaluation("600000.SH", TARGET, "no_signal"),
}
)
service = RunZhixingB1(
BatchReader(source),
store,
evaluator,
loader,
scorer,
pattern_scoring_enabled=True,
)
service.execute(service.prepare("zhixing_b1", TARGET, rerun=False))
assert loader.calls == 1
assert scorer.calls == []
assert store.items[0].status == "selected"
assert store.items[0].pattern_score.status == "failed"
assert store.items[0].signals[0].category.value == "zhixing_b1_original_b1"
assert store.finished is not None
assert store.finished[0:2] == ("run-1", "success")
assert store.finished[2]["failed_count"] == 0
+11
View File
@@ -169,6 +169,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]]
name = "fastdtw"
version = "0.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/43/30f2d8db076f216b15c10db663b46e22d1750b1ebacd7af6e62b83d6ab98/fastdtw-0.3.4.tar.gz", hash = "sha256:2350fa6ec36bcad186eaf81f46eff35181baf04e324f522de8aeb43d0243f64f", size = 133402, upload-time = "2019-10-07T16:02:29.982Z" }
[[package]]
name = "greenlet"
version = "3.5.4"
@@ -892,6 +901,7 @@ source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi" },
{ name = "fastdtw" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "psycopg", extra = ["binary", "pool"] },
@@ -915,6 +925,7 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = ">=1.18.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "fastdtw", specifier = ">=0.3.4" },
{ name = "numpy", specifier = ">=2.4.0" },
{ name = "pandas", specifier = ">=2.3.3" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },