Compare commits
9
Commits
f391020f78
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cae126fafc | ||
|
|
8413aa1d17 | ||
|
|
dbe189348e | ||
|
|
c40f34fac9 | ||
|
|
62dfb5e28f | ||
|
|
9fdf0e11bc | ||
|
|
6f2d5c4fa6 | ||
|
|
4c37463472 | ||
|
|
2273d9190e |
@@ -37,6 +37,3 @@ feature_meta
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
data_provider/._config.json
|
||||
.gstack/
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
缠论 (Chan Theory) technical analysis system for Freqtrade. Implements Chan Zhong Shui Chan's theory for crypto/stock trading, including fractal (分型), stroke (笔), segment (线段), pivot/center (中枢), and buy/sell point (买卖点) detection.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Chan Theory Engine (`Chan*.py`)
|
||||
|
||||
Data processing pipeline (each step feeds the next):
|
||||
|
||||
1. **`ChanKLU.py`** — Raw K-line unit with TA indicators (EMA, MACD, RSI, Bollinger Bands) and candlestick pattern recognition (`Chan_KLU_PATTERN`)
|
||||
2. **`ChanKLC.py`** — Combined K-line: inclusion processing (包含处理), fractal (分型) detection. Linked-list structure with `.next`/`.pre` pointers
|
||||
3. **`ChanBI.py`** — Stroke (笔): basic trend unit connecting alternating fractals
|
||||
4. **`ChanSBI.py`** — Special Stroke: aggregates multiple BI into higher-level units with fractal detection, feeds into SEG
|
||||
5. **`ChanSEG.py`** — Segment (线段): built from SBI strokes
|
||||
6. **`ChanZS.py`** / **`ChanBIZS.py`** — Center/pivot (中枢): consolidation zones (segment-level and stroke-level)
|
||||
7. **`ChanBSP.py`** — Buy/Sell points (买卖点): Type 1/2/3 signals
|
||||
8. **`ChanLun.py`** — Main orchestrator: ties all steps together, entry point
|
||||
9. **`TF_DF.py`** — Timeframe-aware DataFrame processor: resamples data, runs the full pipeline per timeframe, handles multi-timeframe analysis
|
||||
|
||||
### Support modules
|
||||
|
||||
- **`ChanEnum.py`** — All enumerations: K-line types, fractal types, MACD states, buy/sell point types, EMA position/semantic states, K-line patterns
|
||||
- **`ChanCTime.py`** — Chan theory time utility: auto-adaptive day understanding (e.g. crypto 24h vs stock market hours)
|
||||
- **`ChanMACD.py`** / **`ChanMACDHistSet.py`** / **`ChanMACDSeg.py`** / **`ChanMACDUnitTF.py`** — MACD state analysis and divergence detection
|
||||
- **`ChanPY.py`** — Consolidation (盘整) analysis
|
||||
- **`ChanHeng.py`** — Sideways market analysis
|
||||
- **`Chan_FX_Box.py`** — Fractal box (分型箱体) detection
|
||||
- **`ChanLun_Classifier.py`** — Standalone classifier script: runs full pipeline and classifies market states
|
||||
|
||||
### Services
|
||||
|
||||
- **`data_provider/`** — FastAPI data service: fetches crypto data from Binance via CCXT, caches to CSV, serves REST API + WebSocket. Synthesizes derived timeframes (e.g. 5m/15m/4h from 1m/1h base). Port 9009.
|
||||
- **`web/`** — Flask web UI for interactive chart visualization with Chan theory overlays. Port 8123.
|
||||
- **`strategies/`** — Freqtrade trading strategies using the Chan theory engine (53 strategies)
|
||||
- **`config/`** — Freqtrade JSON config files per pair/timeframe
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Exchange (CCXT) → data_provider (CSV cache) → Freqtrade → Strategy → ChanLun → TF_DF
|
||||
→ KLU → KLC → BI → SBI → SEG → ZS → BSP
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Freqtrade Trading
|
||||
|
||||
```bash
|
||||
# Live trade
|
||||
freqtrade trade -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies
|
||||
|
||||
# Backtest
|
||||
freqtrade backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20251008-
|
||||
|
||||
# Download data
|
||||
freqtrade download-data -c ./user_data/Chan/config/<config>.json -t 1m 1h 1d --pairs BTC/USDT:USDT --timerange=20240101-
|
||||
|
||||
# Hyperopt
|
||||
freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/<config>.json -e 200 --timerange=20250201-20250901
|
||||
|
||||
# Plot
|
||||
freqtrade plot-dataframe --strategy <StrategyName> --datadir user_data/data/binance -c ./user_data/Chan/config/<config>.json --timerange=20250721-
|
||||
```
|
||||
|
||||
### Data Provider
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
cd data_provider && docker compose up -d
|
||||
|
||||
# Direct
|
||||
cd data_provider && python main.py
|
||||
|
||||
# With custom config
|
||||
CONFIG_PATH=./config.json python main.py
|
||||
```
|
||||
|
||||
### Web UI
|
||||
|
||||
```bash
|
||||
cd web && python app.py
|
||||
# or via gunicorn:
|
||||
gunicorn -w 4 -b 0.0.0.0:8123 app:app
|
||||
|
||||
# Deploy scripts:
|
||||
cd web && ./deploy.sh # standard
|
||||
cd web && ./deploy_venv.sh # Ubuntu 22.04+ (venv)
|
||||
```
|
||||
|
||||
### Docker (Freqtrade)
|
||||
|
||||
```bash
|
||||
sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/<config>.json --strategy <StrategyName> --strategy-path ./user_data/Chan/strategies --timerange=20250721-
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- All Chan theory classes are prefixed with `Chan` (e.g., `ChanBI`, `ChanZS`)
|
||||
- Strategies import `ChanLun` and add `sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))` to import from parent
|
||||
- MACD params: `MACD(26, 52, 9)` by default (slow period 52 instead of standard 26)
|
||||
- Enums in `ChanEnum.py` use `auto()` values
|
||||
- `ChanKLC` is a linked-list style data structure with `.next`/`.pre` pointers
|
||||
- The `TF_DF` class is the primary data container per timeframe
|
||||
- K-line direction uses `Chan_KLINE_DIR` (UP/DOWN/COMBINE/INCLUDED)
|
||||
- All text comments/commits are in Chinese
|
||||
+2
-2
@@ -372,14 +372,14 @@ class ChanKLC():
|
||||
end_time = self.next.end_time
|
||||
high = self.high
|
||||
low = self.pre.low if self.pre.low < self.next.low else self.next.low
|
||||
if self.next.close < self.pre.low or True:
|
||||
if self.next.close < self.pre.low:
|
||||
display = True
|
||||
elif self.fx == Chan_FX_TYPE.BOTTOM:
|
||||
start_time = self.pre.end_time
|
||||
end_time = self.next.end_time
|
||||
high = self.pre.high if self.pre.high > self.next.high else self.next.high
|
||||
low = self.low
|
||||
if self.next.close > self.pre.high or True:
|
||||
if self.next.close > self.pre.high:
|
||||
display = True
|
||||
if high > 0 and self.next.end_time and display:
|
||||
#print(start_time, end_time, high, low)
|
||||
|
||||
+12
@@ -96,6 +96,7 @@ class ChanKLU:
|
||||
self.trend = trend
|
||||
def set_separate_div(self, separate_div):
|
||||
self.separate_div = separate_div
|
||||
bb2633_status = self.check_bb2633()
|
||||
if self.klc and self.klc.pre and self.klc.next:
|
||||
fx = self.check_fx_dir(self.klc.pre, self.klc.next)
|
||||
if fx == Chan_FX_TYPE.TOP:
|
||||
@@ -108,6 +109,17 @@ class ChanKLU:
|
||||
self.separate_div = separate_div
|
||||
else:
|
||||
self.separate_div = 0
|
||||
if bb2633_status == 0:
|
||||
self.separate_div = 0
|
||||
def check_bb2633(self, threadhold=300):
|
||||
#print(self.time, self.high, self.bb2633upper, self.low, self.bb2633lower)
|
||||
if abs(self.high - self.bb2633upper) < threadhold:
|
||||
#print(self.time, self.high, self.bb2633upper)
|
||||
return 1
|
||||
if abs(self.low - self.bb2633lower) < threadhold:
|
||||
#print(self.time, self.low, self.bb2633lower)
|
||||
return -1
|
||||
return 0
|
||||
def check_fx_dir(self, pre, next):
|
||||
fx = Chan_FX_TYPE.UNKNOWN
|
||||
if pre.klc_fx_type == Chan_KLC_FX.TOP1 or pre.klc_fx_type == Chan_KLC_FX.TOP2 or next.klc_fx_type == Chan_KLC_FX.TOP1 or next.klc_fx_type == Chan_KLC_FX.TOP2 or self.klc.klc_fx_type == Chan_KLC_FX.TOP1 or self.klc.klc_fx_type == Chan_KLC_FX.TOP2:
|
||||
|
||||
-14
@@ -24,7 +24,6 @@ from decimal import Decimal
|
||||
import numpy as np
|
||||
from ChanMACD import ChanMACD
|
||||
from TF_DF import TF_DF
|
||||
from ChanZone import StructureZone, StructureZoneConfig, analyze_structure_zones
|
||||
|
||||
class ChanLun():
|
||||
def __init__(self):
|
||||
@@ -126,18 +125,7 @@ class ChanLun():
|
||||
|
||||
|
||||
|
||||
def get_bsp_state(self, dataframe):
|
||||
return self.tf_df.get_bsp_state(dataframe)
|
||||
|
||||
def get_structure_zones(self, current_price=None, config=None):
|
||||
if config is None:
|
||||
config = StructureZoneConfig()
|
||||
return analyze_structure_zones(
|
||||
self.tf_df_dict,
|
||||
self.ema_symbols,
|
||||
current_price=current_price,
|
||||
config=config,
|
||||
)
|
||||
# TF_DF methods ------------------------------------------
|
||||
def get_ema_state(self, dataframe):
|
||||
return self.tf_df.get_ema_state(dataframe)
|
||||
@@ -178,8 +166,6 @@ class ChanLun():
|
||||
def cal_bi_zs_list(self, bi_list):
|
||||
#return self.tf_df.cal_bi_zs(bi_list)
|
||||
return self.tf_df.cal_bi_zs_list(bi_list)
|
||||
def get_bi_zs_list(self, bi_list):
|
||||
return self.tf_df.get_bi_zs_list(bi_list)
|
||||
def get_decimal(self, value):
|
||||
return Decimal("{:.2f}".format(value))
|
||||
def get_klc_list(self, klu_list):
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ class ChanMACD():
|
||||
if self.klu_list:
|
||||
for klu in self.klu_list:
|
||||
hist = klu.macdhist
|
||||
signal = False
|
||||
singal = False
|
||||
if klu.pre and klu.next:
|
||||
if klu.signal > 0:
|
||||
signal = klu.pre.signal > klu.signal and klu.next.signal < klu.signal
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Data Provider URL (existing chan data_provider service)
|
||||
PROVIDER_URL=http://127.0.0.1:80
|
||||
|
||||
# Database path
|
||||
DB_PATH=data/macro.db
|
||||
|
||||
# Telegram (reuse bsp_monitor config)
|
||||
# TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
# TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# AI API (for daily report, Phase 5+)
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
@@ -1 +0,0 @@
|
||||
data/
|
||||
@@ -1,9 +0,0 @@
|
||||
"""
|
||||
ChanMacro — Crypto Market Memory System (Signal Expectancy Engine).
|
||||
|
||||
V1: 4 factors (Price Structure, Breadth, OI State, Volatility Regime)
|
||||
3 regimes (TREND / RANGE / PANIC)
|
||||
Factor-locked: Regime = f(Price, Breadth, Vol) — forever.
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -1,364 +0,0 @@
|
||||
"""
|
||||
cli.py — Command-line interface for ChanMacro.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from datetime import date as Date, datetime, timedelta
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("chanmacro")
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> Date:
|
||||
"""Parse YYYY-MM-DD string to Date."""
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def _build_market_state(target: Date) -> tuple:
|
||||
"""Shared helper: compute all scores → (MarketStateVector, RegimeResult)."""
|
||||
from config import config
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketStateVector
|
||||
|
||||
ps = PriceStructureScorer().compute(target)
|
||||
br = BreadthScorer().compute(target)
|
||||
oi = OIMatrixScorer().compute(target)
|
||||
vol = VolatilityRegimeScorer().compute(target)
|
||||
|
||||
detector = RegimeDetector()
|
||||
detector.load_state(config.db_path)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps, breadth_score=br,
|
||||
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
return state, r
|
||||
|
||||
|
||||
def cmd_fetch(args):
|
||||
"""Fetch raw data and store to DB."""
|
||||
from database import init_db
|
||||
from fetchers.ohlcv import OHLCVFetcher
|
||||
from fetchers.breadth import BreadthFetcher
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
|
||||
module = args.module or "all"
|
||||
|
||||
if module in ("ohlcv", "all"):
|
||||
logger.info(f"Fetching OHLCV for {target}...")
|
||||
fetcher = OHLCVFetcher()
|
||||
df = fetcher.fetch(target)
|
||||
if not df.empty:
|
||||
n = fetcher.store_df(df)
|
||||
logger.info(f"OHLCV: stored {n} rows")
|
||||
|
||||
if module in ("breadth", "all"):
|
||||
logger.info(f"Fetching Breadth for {target}...")
|
||||
fetcher = BreadthFetcher()
|
||||
record = fetcher.fetch(target)
|
||||
if record:
|
||||
fetcher.store(record=record)
|
||||
logger.info(f"Breadth: stored (adv={record.get('advance_top50')}, "
|
||||
f"dec={record.get('decline_top50')}, "
|
||||
f"ema20={record.get('above_ema20_top50')})")
|
||||
|
||||
if module in ("derivatives", "all"):
|
||||
logger.info(f"Fetching Derivatives for {target}...")
|
||||
from fetchers.derivatives import DerivativesFetcher
|
||||
fetcher = DerivativesFetcher()
|
||||
records = fetcher.fetch(target)
|
||||
if records:
|
||||
n = fetcher.store(records=records)
|
||||
logger.info(f"Derivatives: stored {n} records")
|
||||
|
||||
|
||||
def cmd_score(args):
|
||||
"""Compute all factor scores and regime for a date."""
|
||||
from database import init_db, get_connection
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
logger.info(f"Computing scores for {target}...")
|
||||
|
||||
state, regime_result = _build_market_state(target)
|
||||
|
||||
# Output
|
||||
ps = state.price_structure_score
|
||||
br = state.breadth_score
|
||||
oi = state.oi_matrix_score
|
||||
vol = state.volatility_regime_score
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {target} Market State")
|
||||
print(f"{'='*60}")
|
||||
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f}, "
|
||||
f"v={state.regime_version})")
|
||||
print(f" Maturity: {state.regime_maturity_score:.0f}/100")
|
||||
print(f" Breadth: {state.breadth_bucket.value} "
|
||||
f"(T20={state.breadth_top20:.0f} T30={state.breadth_top30:.0f} "
|
||||
f"T50={state.breadth_top50:.0f} div={state.breadth_divergence:+.0f})")
|
||||
print(f" OI State: {state.oi_state.value}")
|
||||
print(f" Volatility: {state.volatility_regime.value}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Scores:")
|
||||
print(f" Price Structure: {ps.score:.0f} {ps.label}")
|
||||
print(f" Breadth: {br.score:.0f} {br.breadth_bucket.value}")
|
||||
print(f" OI Matrix: {oi.score:.0f} {oi.oi_state.value}")
|
||||
print(f" Volatility: {vol.score:.0f} {vol.vol_regime.value}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Market State Hash: {state.market_state_hash}")
|
||||
print()
|
||||
|
||||
# Store regime to DB
|
||||
conn = get_connection()
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date, regime, confidence, regime_version, maturity_score, all_scores_json,
|
||||
prior_regime, confirmation_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
str(target),
|
||||
state.regime.value,
|
||||
state.regime_confidence,
|
||||
state.regime_version,
|
||||
state.regime_maturity_score,
|
||||
json.dumps(regime_result.all_scores),
|
||||
regime_result.prior_regime.value if regime_result.prior_regime else None,
|
||||
regime_result.confirmation_days,
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def cmd_regime(args):
|
||||
"""Show regime history."""
|
||||
from database import get_connection
|
||||
days = args.days or 30
|
||||
conn = get_connection()
|
||||
rows = conn.execute(
|
||||
"SELECT date, regime, confidence, maturity_score, confirmation_days "
|
||||
"FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f" Regime History (last {days} days)")
|
||||
print(f"{'='*50}")
|
||||
for r in rows:
|
||||
print(f" {r['date']} {r['regime']:7s} conf={r['confidence']:.2f} "
|
||||
f"mat={r['maturity_score']:.0f} days={r['confirmation_days']}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_track(args):
|
||||
"""Record a trading signal with current market state."""
|
||||
from database import init_db
|
||||
from expectancy.tracker import SignalTracker
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
|
||||
logger.info(f"Recording {args.signal} on {target} @ {args.price}")
|
||||
|
||||
state, _ = _build_market_state(target)
|
||||
|
||||
tracker = SignalTracker()
|
||||
rid = tracker.record(
|
||||
date=target, signal_type=args.signal, entry_price=args.price,
|
||||
state=state, signal_grade=args.grade, signal_strength=args.strength,
|
||||
)
|
||||
logger.info(f"Signal recorded: id={rid}")
|
||||
|
||||
|
||||
def cmd_backfill(args):
|
||||
"""Backfill historical scores and/or signals."""
|
||||
from datetime import date as Date, timedelta
|
||||
from database import init_db, get_connection
|
||||
from fetchers.ohlcv import OHLCVFetcher
|
||||
|
||||
start = parse_date(args.from_date)
|
||||
end = parse_date(args.to_date) if args.to_date else Date.today()
|
||||
init_db()
|
||||
|
||||
# First, backfill OHLCV data
|
||||
logger.info(f"Backfilling OHLCV from {start} to {end}...")
|
||||
fetcher = OHLCVFetcher()
|
||||
df = fetcher.fetch()
|
||||
if not df.empty:
|
||||
fetcher.store_df(df)
|
||||
|
||||
# Then compute scores for each date
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
|
||||
detector = RegimeDetector()
|
||||
conn = get_connection()
|
||||
|
||||
current = start
|
||||
count = 0
|
||||
while current <= end:
|
||||
try:
|
||||
ps = PriceStructureScorer().compute(current)
|
||||
br = BreadthScorer().compute(current)
|
||||
if br.score == 50.0 and br.label == "No Data":
|
||||
current += timedelta(days=1)
|
||||
continue
|
||||
|
||||
oi = OIMatrixScorer().compute(current)
|
||||
vol = VolatilityRegimeScorer().compute(current)
|
||||
r = detector.detect(ps.score, br.breadth_top50,
|
||||
vol.vol_regime.value, current)
|
||||
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date, regime, confidence, regime_version, maturity_score,
|
||||
all_scores_json, confirmation_days)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
str(current), r.regime.value, r.confidence,
|
||||
r.regime_version, r.maturity_score,
|
||||
json.dumps(r.all_scores), r.confirmation_days,
|
||||
))
|
||||
count += 1
|
||||
if count % 30 == 0:
|
||||
conn.commit()
|
||||
logger.info(f" Backfilled {count} days... ({current})")
|
||||
except Exception as e:
|
||||
logger.debug(f" Skip {current}: {e}")
|
||||
current += timedelta(days=1)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Backfill complete: {count} days scored")
|
||||
|
||||
|
||||
def cmd_expectancy(args):
|
||||
"""Query signal expectancy for current market state."""
|
||||
from database import init_db
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
target = parse_date(args.date) if args.date else Date.today()
|
||||
init_db()
|
||||
|
||||
state, _ = _build_market_state(target)
|
||||
|
||||
engine = BayesianExpectancyEngine()
|
||||
signal = args.signal or "B3"
|
||||
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {target} Signal Expectancy: {signal}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Regime: {state.regime.value} (conf={state.regime_confidence:.2f})")
|
||||
print(f" Breadth: {state.breadth_bucket.value} (T50={state.breadth_top50:.0f})")
|
||||
print(f" OI State: {state.oi_state.value}")
|
||||
print(f" Volatility: {state.volatility_regime.value}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
for layer in report.layers:
|
||||
print(f" {layer.name:15s} N={layer.samples:4d} eff={layer.effective_samples:.0f} "
|
||||
f"raw={layer.raw_winrate or 0:.1%} post={layer.posterior_winrate:.1%} "
|
||||
f"ret={layer.avg_return or 0:+.1f}%")
|
||||
|
||||
print(f"{'='*60}")
|
||||
print(f" Final: {report.final_estimate:.1%} "
|
||||
f"(sufficiency={report.sufficiency.value}, source={report.source})")
|
||||
if report.profit_factor:
|
||||
print(f" PF={report.profit_factor} MAE={report.max_adverse_excursion}%")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="ChanMacro — Crypto Market Memory System"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", help="Commands")
|
||||
|
||||
# fetch
|
||||
p_fetch = sub.add_parser("fetch", help="Fetch raw data")
|
||||
p_fetch.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||
p_fetch.add_argument("--module", choices=["ohlcv", "breadth", "derivatives", "all"])
|
||||
|
||||
# score
|
||||
p_score = sub.add_parser("score", help="Compute scores and regime")
|
||||
p_score.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||
|
||||
# regime
|
||||
p_regime = sub.add_parser("regime", help="Show regime history")
|
||||
p_regime.add_argument("--days", type=int, default=30)
|
||||
|
||||
# track
|
||||
p_track = sub.add_parser("track", help="Record a trading signal")
|
||||
p_track.add_argument("--date", help="Signal date (YYYY-MM-DD)")
|
||||
p_track.add_argument("--signal", required=True, help="Signal type (B1/B2/B3/S1/S2/S3)")
|
||||
p_track.add_argument("--price", type=float, required=True, help="Entry price")
|
||||
p_track.add_argument("--grade", choices=["A", "B", "C"], help="Signal quality grade")
|
||||
p_track.add_argument("--strength", type=float, help="Signal strength 0-100")
|
||||
|
||||
# backfill
|
||||
p_backfill = sub.add_parser("backfill", help="Backfill historical scores")
|
||||
p_backfill.add_argument("--from", dest="from_date", required=True)
|
||||
p_backfill.add_argument("--to", dest="to_date")
|
||||
|
||||
# expectancy
|
||||
p_expectancy = sub.add_parser("expectancy", help="Query signal expectancy")
|
||||
p_expectancy.add_argument("--date", help="Target date (YYYY-MM-DD)")
|
||||
p_expectancy.add_argument("--signal", default="B3", help="Signal type")
|
||||
|
||||
# validate
|
||||
p_validate = sub.add_parser("validate", help="Run validation framework")
|
||||
|
||||
# serve
|
||||
p_serve = sub.add_parser("serve", help="Start web dashboard")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "fetch":
|
||||
cmd_fetch(args)
|
||||
elif args.command == "score":
|
||||
cmd_score(args)
|
||||
elif args.command == "regime":
|
||||
cmd_regime(args)
|
||||
elif args.command == "track":
|
||||
cmd_track(args)
|
||||
elif args.command == "backfill":
|
||||
cmd_backfill(args)
|
||||
elif args.command == "expectancy":
|
||||
cmd_expectancy(args)
|
||||
elif args.command == "validate":
|
||||
from validation.reporter import ValidationReporter
|
||||
report = ValidationReporter().run_all()
|
||||
print(report)
|
||||
elif args.command == "serve":
|
||||
logger.info("Web dashboard not yet implemented (Phase 7)")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"provider_url": "https://provider.jackyu66.com",
|
||||
"db_path": "data/macro.db",
|
||||
"btc_symbol": "BTC/USDT:USDT",
|
||||
"regime_version": "v1_price_breadth_vol",
|
||||
"half_life_days": 180,
|
||||
"sufficiency_min_effective": 30,
|
||||
"sufficiency_low": 50,
|
||||
"sufficiency_medium": 100,
|
||||
"level_min_samples": 50,
|
||||
"knn_max_distance": 0.35,
|
||||
"knn_k": 200,
|
||||
"oi_price_threshold_pct": 0.5,
|
||||
"oi_oi_threshold_pct": 0.5,
|
||||
"vol_low_threshold": 2.0,
|
||||
"vol_high_threshold": 5.0,
|
||||
"vol_explosive_threshold": 10.0,
|
||||
"regime_w_price": 0.35,
|
||||
"regime_w_breadth": 0.50,
|
||||
"regime_w_vol": 0.15,
|
||||
"trend_w_price": 0.30,
|
||||
"trend_w_breadth": 0.70
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
config.py — Global configuration for ChanMacro.
|
||||
|
||||
All weights, thresholds, and paths are configurable.
|
||||
V1 weights are deliberately simple; they will be tuned via Phase 0 validation.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Global configuration. Override via config.json or env vars."""
|
||||
|
||||
# ── Paths ──────────────────────────────────────────────
|
||||
db_path: str = "data/macro.db"
|
||||
data_dir: str = "data"
|
||||
|
||||
# ── Data Provider ──────────────────────────────────────
|
||||
provider_url: str = "https://provider.jackyu66.com"
|
||||
btc_symbol: str = "BTC/USDT:USDT"
|
||||
top50_symbols: list[str] = field(default_factory=lambda: [
|
||||
"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT",
|
||||
"BNB/USDT:USDT", "XRP/USDT:USDT", "DOGE/USDT:USDT",
|
||||
"SUI/USDT:USDT", "TON/USDT:USDT", "ZEC/USDT:USDT",
|
||||
"1000PEPE/USDT:USDT", "SAGA/USDT:USDT",
|
||||
"XAU/USDT:USDT", "XAG/USDT:USDT",
|
||||
"CL/USDT:USDT", "BILL/USDT:USDT", "BZ/USDT:USDT",
|
||||
"LAB/USDT:USDT", "CRCL/USDT:USDT", "SNDK/USDT:USDT",
|
||||
"CHIP/USDT:USDT",
|
||||
])
|
||||
|
||||
# ── Breadth ────────────────────────────────────────────
|
||||
breadth_top_n: list[int] = field(default_factory=lambda: [20, 30, 50])
|
||||
breadth_ema_period: int = 20
|
||||
breadth_new_high_window: int = 20
|
||||
|
||||
# ── Regime (factor-locked: Price + Breadth + Vol) ─────
|
||||
regime_version: str = "v1_price_breadth_vol"
|
||||
# Weights for trend_score within regime detection
|
||||
regime_w_price: float = 0.35
|
||||
regime_w_breadth: float = 0.50
|
||||
regime_w_vol: float = 0.15
|
||||
# Weights for panic_score
|
||||
regime_panic_w_anti_trend: float = 0.60
|
||||
regime_panic_w_vol_extreme: float = 0.40
|
||||
|
||||
# ── Price Structure ────────────────────────────────────
|
||||
ps_ema_fast: int = 20
|
||||
ps_ema_mid: int = 60
|
||||
ps_ema_slow: int = 120
|
||||
ps_adx_period: int = 14
|
||||
ps_adx_threshold: int = 25
|
||||
ps_atr_period: int = 14
|
||||
ps_bb_period: int = 20
|
||||
ps_roc_periods: list[int] = field(default_factory=lambda: [5, 10, 20])
|
||||
|
||||
# ── OI Matrix ──────────────────────────────────────────
|
||||
oi_price_threshold_pct: float = 0.5 # min price change% to classify
|
||||
oi_oi_threshold_pct: float = 0.5 # min OI change% to classify
|
||||
|
||||
# ── Volatility Regime ──────────────────────────────────
|
||||
vol_atr_period: int = 14
|
||||
vol_hv_short: int = 20
|
||||
vol_hv_long: int = 60
|
||||
# Thresholds (ATR/Close %)
|
||||
vol_low_threshold: float = 2.0
|
||||
vol_high_threshold: float = 5.0
|
||||
vol_explosive_threshold: float = 10.0
|
||||
|
||||
# ── Trend (L2 aggregation) ─────────────────────────────
|
||||
trend_w_price: float = 0.30
|
||||
trend_w_breadth: float = 0.70
|
||||
|
||||
# ── Maturity Score ─────────────────────────────────────
|
||||
maturity_w_trend: float = 0.50
|
||||
maturity_w_breadth: float = 0.30
|
||||
maturity_w_vol: float = 0.20
|
||||
|
||||
# ── Expectancy ─────────────────────────────────────────
|
||||
half_life_days: int = 180
|
||||
sufficiency_min_effective: int = 30
|
||||
sufficiency_low: int = 50
|
||||
sufficiency_medium: int = 100
|
||||
level_min_samples: int = 50
|
||||
knn_max_distance: float = 0.35
|
||||
knn_k: int = 200
|
||||
|
||||
# ── Validation ─────────────────────────────────────────
|
||||
min_history_days: int = 365
|
||||
regime_min_avg_duration: int = 5
|
||||
regime_max_flip_rate: float = 0.15
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, path: str = "config.json") -> "Config":
|
||||
"""Load config from JSON file, overriding defaults."""
|
||||
import json
|
||||
config = cls()
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
for key, value in data.items():
|
||||
if hasattr(config, key):
|
||||
setattr(config, key, value)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return config
|
||||
|
||||
|
||||
# Global singleton
|
||||
config = Config()
|
||||
@@ -1,224 +0,0 @@
|
||||
"""
|
||||
database.py — SQLite schema initialization and connection management.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = """
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- L0: Raw data tables
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ohlcv_daily (
|
||||
date TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
|
||||
open REAL,
|
||||
high REAL,
|
||||
low REAL,
|
||||
close REAL,
|
||||
volume REAL,
|
||||
ema20 REAL,
|
||||
ema60 REAL,
|
||||
ema120 REAL,
|
||||
atr_14 REAL,
|
||||
bb_width REAL,
|
||||
adx_14 REAL,
|
||||
PRIMARY KEY (date, symbol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS breadth_daily (
|
||||
date TEXT PRIMARY KEY,
|
||||
total_tracked INTEGER DEFAULT 50,
|
||||
advance_top50 INTEGER DEFAULT 0,
|
||||
decline_top50 INTEGER DEFAULT 0,
|
||||
above_ema20_top50 INTEGER DEFAULT 0,
|
||||
new_highs_20d_top50 INTEGER DEFAULT 0,
|
||||
btc_dominance REAL,
|
||||
advance_top20 INTEGER DEFAULT 0,
|
||||
advance_top30 INTEGER DEFAULT 0,
|
||||
above_ema20_top20 INTEGER DEFAULT 0,
|
||||
above_ema20_top30 INTEGER DEFAULT 0,
|
||||
new_highs_20d_top20 INTEGER DEFAULT 0,
|
||||
new_highs_20d_top30 INTEGER DEFAULT 0,
|
||||
fetched_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS derivatives (
|
||||
date TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL DEFAULT 'BTC/USDT:USDT',
|
||||
funding_rate REAL,
|
||||
open_interest REAL,
|
||||
oi_24h_change_pct REAL,
|
||||
long_liquidations REAL,
|
||||
short_liquidations REAL,
|
||||
basis_annualised_pct REAL,
|
||||
source TEXT DEFAULT 'binance',
|
||||
fetched_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (date, symbol)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS etf_flow (
|
||||
date TEXT NOT NULL,
|
||||
product TEXT NOT NULL,
|
||||
net_flow_million REAL NOT NULL,
|
||||
price REAL,
|
||||
source TEXT DEFAULT 'farside',
|
||||
fetched_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (date, product)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stablecoin_supply (
|
||||
date TEXT NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
chain TEXT NOT NULL DEFAULT 'all',
|
||||
supply REAL NOT NULL,
|
||||
source TEXT DEFAULT 'defillama',
|
||||
fetched_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (date, token, chain)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- L3: Regime history
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS regime_history (
|
||||
date TEXT PRIMARY KEY,
|
||||
regime TEXT NOT NULL,
|
||||
confidence REAL,
|
||||
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
|
||||
maturity_score REAL DEFAULT 50.0,
|
||||
all_scores_json TEXT DEFAULT '{}',
|
||||
prior_regime TEXT,
|
||||
confirmation_days INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- ★ signal_features — THE moat
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS signal_features (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL,
|
||||
signal_version TEXT NOT NULL DEFAULT 'b3_v1',
|
||||
symbol TEXT DEFAULT 'BTC/USDT:USDT',
|
||||
|
||||
-- ★★ Version control (most important fields)
|
||||
regime_version TEXT NOT NULL DEFAULT 'v1_price_breadth_vol',
|
||||
signal_grade TEXT,
|
||||
signal_strength REAL,
|
||||
|
||||
-- Market State Vector snapshot
|
||||
regime TEXT NOT NULL,
|
||||
regime_confidence REAL,
|
||||
regime_maturity_score REAL DEFAULT 50.0,
|
||||
market_state_hash TEXT,
|
||||
state_embedding TEXT DEFAULT '[]',
|
||||
breadth_top20 REAL,
|
||||
breadth_top30 REAL,
|
||||
breadth_top50 REAL,
|
||||
breadth_bucket TEXT,
|
||||
breadth_divergence REAL,
|
||||
oi_state TEXT,
|
||||
volatility_regime TEXT,
|
||||
price_structure_score REAL,
|
||||
|
||||
-- Chan context (V5+)
|
||||
chan_trend_direction TEXT,
|
||||
chan_pivot_count INTEGER,
|
||||
chan_divergence_type TEXT,
|
||||
|
||||
-- Outcomes
|
||||
entry_price REAL,
|
||||
result_1d REAL,
|
||||
result_3d REAL,
|
||||
result_5d REAL,
|
||||
result_7d REAL,
|
||||
result_14d REAL,
|
||||
max_favorable_excursion REAL,
|
||||
max_adverse_excursion REAL,
|
||||
is_win_7d INTEGER,
|
||||
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_regime ON signal_features(regime);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_signal ON signal_features(signal_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_oi_state ON signal_features(oi_state);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_date ON signal_features(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_state_hash ON signal_features(market_state_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_regime_version ON signal_features(regime_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_sf_signal_version ON signal_features(signal_version);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- Expectancy cache (raw counts, NOT posteriors)
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expectancy_cache (
|
||||
state_hash TEXT NOT NULL,
|
||||
signal_type TEXT NOT NULL,
|
||||
wins_weighted REAL DEFAULT 0,
|
||||
losses_weighted REAL DEFAULT 0,
|
||||
sum_return_7d REAL DEFAULT 0,
|
||||
sum_return_sq_7d REAL DEFAULT 0,
|
||||
effective_samples REAL DEFAULT 0,
|
||||
sufficiency TEXT DEFAULT 'INSUFFICIENT',
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (state_hash, signal_type)
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- Similarity outcome (KNN weight learning, Phase D)
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS similarity_outcome (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
state_a_hash TEXT,
|
||||
state_b_hash TEXT,
|
||||
distance REAL,
|
||||
actual_return_gap REAL,
|
||||
dimension_weights_json TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ═══════════════════════════════════════════════
|
||||
-- chan_context — Chan theory integration (V1 empty)
|
||||
-- ═══════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chan_context (
|
||||
date TEXT NOT NULL,
|
||||
timeframe TEXT NOT NULL DEFAULT '1d',
|
||||
trend_direction TEXT,
|
||||
trend_strength REAL,
|
||||
pivot_count INTEGER,
|
||||
pivot_level TEXT,
|
||||
signal_type TEXT,
|
||||
signal_strength REAL,
|
||||
divergence_type TEXT,
|
||||
chan_structure_score REAL,
|
||||
alignment_score REAL,
|
||||
raw_context_json TEXT DEFAULT '{}',
|
||||
PRIMARY KEY (date, timeframe)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def init_db(db_path: str = "data/macro.db") -> sqlite3.Connection:
|
||||
"""Initialize database: create directory and all tables."""
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def get_connection(db_path: str = "data/macro.db") -> sqlite3.Connection:
|
||||
"""Get a database connection. Creates tables if first run."""
|
||||
if not os.path.exists(db_path):
|
||||
return init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Expectancy Engine — Signal tracking, Bayesian inference, time decay."""
|
||||
from .tracker import SignalTracker
|
||||
from .decay import TimeDecay
|
||||
from .engine import BayesianExpectancyEngine, SufficiencyGuard
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
expectancy/decay.py — Time-weighted sample decay.
|
||||
|
||||
2024 market structure ≠ 2026 market structure.
|
||||
Recent samples get higher weight via exponential decay.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TimeDecay:
|
||||
"""Exponential time decay for sample weighting."""
|
||||
|
||||
def __init__(self, half_life_days: int = 180):
|
||||
self.half_life = half_life_days
|
||||
self._decay_rate = np.log(2) / half_life_days
|
||||
|
||||
def weight(self, sample_date: Date, reference_date: Optional[Date] = None) -> float:
|
||||
"""
|
||||
Compute decay weight for a sample.
|
||||
weight = exp(-days_ago * decay_rate)
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = Date.today()
|
||||
days = (reference_date - sample_date).days
|
||||
return np.exp(-days * self._decay_rate)
|
||||
|
||||
def weights(self, dates: list[Date], reference_date: Optional[Date] = None) -> np.ndarray:
|
||||
"""Compute decay weights for a list of dates."""
|
||||
return np.array([self.weight(d, reference_date) for d in dates])
|
||||
|
||||
def weighted_win_rate(self, wins: np.ndarray, weights: np.ndarray) -> float:
|
||||
"""Weighted win rate: sum(wins * weights) / sum(weights)."""
|
||||
total_weight = weights.sum()
|
||||
if total_weight == 0:
|
||||
return 0.0
|
||||
return float((wins * weights).sum() / total_weight)
|
||||
|
||||
def weighted_mean(self, values: np.ndarray, weights: np.ndarray) -> float:
|
||||
"""Weighted mean."""
|
||||
total_weight = weights.sum()
|
||||
if total_weight == 0:
|
||||
return 0.0
|
||||
return float((values * weights).sum() / total_weight)
|
||||
|
||||
def effective_samples(self, weights: np.ndarray) -> float:
|
||||
"""Effective number of samples after decay weighting."""
|
||||
return float(weights.sum())
|
||||
|
||||
@staticmethod
|
||||
def weight_at_age(days_ago: int, half_life_days: int = 180) -> float:
|
||||
"""Quick weight lookup for a given age in days."""
|
||||
return np.exp(-days_ago * np.log(2) / half_life_days)
|
||||
@@ -1,295 +0,0 @@
|
||||
"""
|
||||
expectancy/engine.py — Bayesian Expectancy Engine.
|
||||
|
||||
Core algorithm:
|
||||
1. LeveledExpectancy: filter layer-by-layer, stop at highest valid level
|
||||
2. Empirical Bayes prior: prior = signal's global historical winrate
|
||||
3. Dynamic Beta strength: adaptive to sample size
|
||||
4. Time decay: recent samples weighted higher (half_life=180d)
|
||||
5. SufficiencyGuard: refuse output if effective_samples < 30
|
||||
6. KNN Fallback: similarity search when strict filtering fails (Phase D)
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from models import (
|
||||
MarketStateVector, ExpectancyReport, ExpectancyLayer,
|
||||
SufficiencyLevel, MarketRegime,
|
||||
)
|
||||
from config import config
|
||||
from .decay import TimeDecay
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SufficiencyGuard:
|
||||
"""Prevents trading advice from insufficient samples."""
|
||||
|
||||
def __init__(self, min_effective: int = 30, low: int = 50, medium: int = 100):
|
||||
self.MIN = min_effective
|
||||
self.LOW = low
|
||||
self.MEDIUM = medium
|
||||
|
||||
def evaluate(self, effective_samples: float) -> SufficiencyLevel:
|
||||
if effective_samples < self.MIN:
|
||||
return SufficiencyLevel.INSUFFICIENT
|
||||
elif effective_samples < self.LOW:
|
||||
return SufficiencyLevel.LOW
|
||||
elif effective_samples < self.MEDIUM:
|
||||
return SufficiencyLevel.MEDIUM
|
||||
return SufficiencyLevel.HIGH
|
||||
|
||||
|
||||
class BayesianExpectancyEngine:
|
||||
"""
|
||||
Leveled Bayesian Expectancy Engine.
|
||||
|
||||
Query layers from coarse to fine. Stop when effective_samples drops below threshold.
|
||||
Uses Empirical Bayes prior (signal's global winrate, not fixed 50%).
|
||||
"""
|
||||
|
||||
# Expectancy query levels: name → WHERE clause template
|
||||
LEVELS = [
|
||||
("Base", "signal_type = '{signal}'"),
|
||||
("+ Regime", "signal_type = '{signal}' AND regime = '{regime}'"),
|
||||
("+ Breadth", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}'"),
|
||||
("+ OI State", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}'"),
|
||||
("+ Volatility", "signal_type = '{signal}' AND regime = '{regime}' AND breadth_bucket = '{breadth}' AND oi_state = '{oi}' AND volatility_regime = '{vol}'"),
|
||||
]
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None,
|
||||
half_life_days: int = 180,
|
||||
level_min_samples: int = 50):
|
||||
self.db_path = db_path or config.db_path
|
||||
self.decay = TimeDecay(half_life_days)
|
||||
self.guard = SufficiencyGuard(
|
||||
min_effective=config.sufficiency_min_effective,
|
||||
low=config.sufficiency_low,
|
||||
medium=config.sufficiency_medium,
|
||||
)
|
||||
self.level_min = level_min_samples
|
||||
|
||||
def estimate(self, state: MarketStateVector,
|
||||
signal_type: str = "B3",
|
||||
target_date: Optional[Date] = None) -> ExpectancyReport:
|
||||
"""
|
||||
Compute layered Bayesian expectancy for a signal in current market state.
|
||||
|
||||
Returns the estimate at the deepest level with >= level_min effective samples.
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = Date.today()
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
# Get global signal winrate for Empirical Bayes prior
|
||||
global_rate = self._global_winrate(conn, signal_type)
|
||||
|
||||
layers = []
|
||||
best_result = None
|
||||
|
||||
for level_name, template in self.LEVELS:
|
||||
where = template.format(
|
||||
signal=signal_type,
|
||||
regime=state.regime.value,
|
||||
breadth=state.breadth_bucket.value,
|
||||
oi=state.oi_state.value,
|
||||
vol=state.volatility_regime.value,
|
||||
)
|
||||
query = f"SELECT * FROM signal_features WHERE {where}"
|
||||
df = pd.read_sql_query(query, conn)
|
||||
|
||||
if df.empty:
|
||||
layers.append(ExpectancyLayer(
|
||||
name=level_name, posterior_winrate=0.0,
|
||||
samples=0, effective_samples=0.0,
|
||||
))
|
||||
continue
|
||||
|
||||
# Time-weighted stats
|
||||
dates_list = [Date.fromisoformat(d) for d in df["date"]]
|
||||
weights = self.decay.weights(dates_list, target_date)
|
||||
eff_n = self.decay.effective_samples(weights)
|
||||
|
||||
wins = pd.to_numeric(df["is_win_7d"].fillna(0), errors="coerce").fillna(0).values
|
||||
returns = pd.to_numeric(df["result_7d"].fillna(0), errors="coerce").fillna(0).values
|
||||
|
||||
raw_wr = float(wins.mean()) if len(wins) > 0 else 0.0
|
||||
weighted_wr = self.decay.weighted_win_rate(wins, weights)
|
||||
weighted_ret = self.decay.weighted_mean(returns, weights)
|
||||
|
||||
# Empirical Bayes posterior
|
||||
posterior = self._bayesian_posterior(
|
||||
global_rate=global_rate,
|
||||
wins=wins.sum(),
|
||||
samples=len(df),
|
||||
)
|
||||
|
||||
layer = ExpectancyLayer(
|
||||
name=level_name,
|
||||
posterior_winrate=round(posterior, 4),
|
||||
raw_winrate=round(raw_wr, 4),
|
||||
samples=len(df),
|
||||
effective_samples=round(eff_n, 1),
|
||||
avg_return=round(weighted_ret, 2),
|
||||
)
|
||||
layers.append(layer)
|
||||
|
||||
# Level-based fallback: keep going while samples sufficient
|
||||
if eff_n >= self.level_min:
|
||||
best_result = layer
|
||||
|
||||
conn.close()
|
||||
|
||||
if best_result is None and layers:
|
||||
# Fallback to the deepest layer that had any samples
|
||||
for layer in reversed(layers):
|
||||
if layer.samples > 0:
|
||||
best_result = layer
|
||||
break
|
||||
|
||||
if best_result is None:
|
||||
return ExpectancyReport(
|
||||
signal_type=signal_type,
|
||||
date=target_date,
|
||||
layers=layers,
|
||||
final_estimate=0.0,
|
||||
sufficiency=SufficiencyLevel.INSUFFICIENT,
|
||||
source="insufficient",
|
||||
)
|
||||
|
||||
sufficiency = self.guard.evaluate(
|
||||
best_result.effective_samples
|
||||
)
|
||||
|
||||
# Compute profit factor and MAE from the SAME level as best_result
|
||||
profit_factor = None
|
||||
avg_mae = None
|
||||
if best_result and best_result.samples > 0:
|
||||
# Re-query the level that produced best_result
|
||||
best_level_idx = next(
|
||||
i for i, l in enumerate(layers) if l.name == best_result.name
|
||||
)
|
||||
where = self.LEVELS[best_level_idx][1].format(
|
||||
signal=signal_type, regime=state.regime.value,
|
||||
breadth=state.breadth_bucket.value, oi=state.oi_state.value,
|
||||
vol=state.volatility_regime.value,
|
||||
)
|
||||
query = f"SELECT result_7d, max_adverse_excursion FROM signal_features WHERE {where}"
|
||||
conn2 = sqlite3.connect(self.db_path)
|
||||
df_detail = pd.read_sql_query(query, conn2)
|
||||
conn2.close()
|
||||
if not df_detail.empty:
|
||||
returns_7d = df_detail["result_7d"].dropna()
|
||||
if len(returns_7d) > 0:
|
||||
gains = returns_7d[returns_7d > 0].sum()
|
||||
losses = abs(returns_7d[returns_7d < 0].sum())
|
||||
profit_factor = round(gains / losses, 2) if losses > 0 else None
|
||||
maes = df_detail["max_adverse_excursion"].dropna()
|
||||
if len(maes) > 0:
|
||||
avg_mae = round(float(maes.mean()), 2)
|
||||
|
||||
return ExpectancyReport(
|
||||
signal_type=signal_type,
|
||||
date=target_date,
|
||||
layers=layers,
|
||||
final_estimate=round(best_result.posterior_winrate, 4),
|
||||
sufficiency=sufficiency,
|
||||
prior_strength=self._prior_strength(best_result.samples),
|
||||
half_life_days=self.decay.half_life,
|
||||
source="bayesian",
|
||||
avg_return_7d=best_result.avg_return,
|
||||
profit_factor=profit_factor,
|
||||
max_adverse_excursion=avg_mae,
|
||||
)
|
||||
|
||||
def _global_winrate(self, conn: sqlite3.Connection,
|
||||
signal_type: str) -> float:
|
||||
"""Get global historical winrate for a signal type (Empirical Bayes prior)."""
|
||||
row = conn.execute(
|
||||
"SELECT AVG(is_win_7d) as wr, COUNT(*) as cnt "
|
||||
"FROM signal_features WHERE signal_type = ? AND is_win_7d IS NOT NULL",
|
||||
(signal_type,)
|
||||
).fetchone()
|
||||
if row and row[1] and row[1] > 0:
|
||||
return float(row[0])
|
||||
return 0.50 # default: neutral
|
||||
|
||||
def _prior_strength(self, samples: int) -> int:
|
||||
"""Dynamic prior strength based on sample count."""
|
||||
if samples < 100:
|
||||
return 20 # Beta(10,10)
|
||||
elif samples < 500:
|
||||
return 40 # Beta(20,20)
|
||||
else:
|
||||
return 100 # Beta(50,50) — data dominates
|
||||
|
||||
def _bayesian_posterior(self, global_rate: float, wins: float,
|
||||
samples: int) -> float:
|
||||
"""
|
||||
Empirical Bayes posterior: prior = global signal winrate.
|
||||
|
||||
posterior = (alpha + wins) / (alpha + beta + samples)
|
||||
where alpha/(alpha+beta) = global_rate
|
||||
"""
|
||||
prior_strength = self._prior_strength(samples)
|
||||
alpha = max(global_rate * prior_strength, 1.0) # floor at 1 to ensure shrinkage
|
||||
beta = max((1 - global_rate) * prior_strength, 1.0)
|
||||
return (alpha + wins) / (alpha + beta + samples)
|
||||
|
||||
def precompute_cache(self):
|
||||
"""
|
||||
Precompute expectancy for all state_hashes in signal_features.
|
||||
Populates expectancy_cache table with raw weighted counts (not posteriors).
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
hashes = conn.execute(
|
||||
"SELECT DISTINCT market_state_hash, signal_type FROM signal_features"
|
||||
).fetchall()
|
||||
|
||||
today = Date.today()
|
||||
count = 0
|
||||
|
||||
for row in hashes:
|
||||
h = row["market_state_hash"]
|
||||
sig = row["signal_type"]
|
||||
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, is_win_7d, result_7d "
|
||||
"FROM signal_features WHERE market_state_hash = ? AND signal_type = ?",
|
||||
conn, params=(h, sig)
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
continue
|
||||
|
||||
dates_list = [Date.fromisoformat(d) for d in df["date"]]
|
||||
weights = self.decay.weights(dates_list, today)
|
||||
wins_w = (df["is_win_7d"].fillna(0).values * weights).sum()
|
||||
losses_w = ((1 - df["is_win_7d"].fillna(0)).values * weights).sum()
|
||||
ret_sum = (df["result_7d"].fillna(0).values * weights).sum()
|
||||
ret_sq = ((df["result_7d"].fillna(0).values ** 2) * weights).sum()
|
||||
eff_n = weights.sum()
|
||||
|
||||
sufficiency = self.guard.evaluate(eff_n).value
|
||||
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO expectancy_cache
|
||||
(state_hash, signal_type, wins_weighted, losses_weighted,
|
||||
sum_return_7d, sum_return_sq_7d, effective_samples, sufficiency)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (h, sig, wins_w, losses_w, ret_sum, ret_sq, eff_n, sufficiency))
|
||||
count += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(f"Precomputed expectancy cache: {count} state×signal combos")
|
||||
return count
|
||||
@@ -1,271 +0,0 @@
|
||||
"""
|
||||
expectancy/tracker.py — SignalTracker: records signals with full market state
|
||||
and computes forward outcomes.
|
||||
|
||||
This is the entry point for populating signal_features — THE moat table.
|
||||
"""
|
||||
|
||||
from datetime import date as Date, timedelta
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import json
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from models import (
|
||||
MarketStateVector, SignalFeatureRecord, MarketRegime,
|
||||
OIState, BreadthBucket, VolRegime, SignalGrade,
|
||||
)
|
||||
from config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SignalTracker:
|
||||
"""
|
||||
Records trading signals with full market state context.
|
||||
|
||||
Usage:
|
||||
tracker = SignalTracker()
|
||||
tracker.record(
|
||||
date=Date(2026, 6, 24),
|
||||
signal_type="B3",
|
||||
entry_price=96500.0,
|
||||
state=market_state_vector, # from scoring pipeline
|
||||
signal_grade="A",
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def record(self, date: Date, signal_type: str, entry_price: float,
|
||||
state: MarketStateVector,
|
||||
signal_version: str = "b3_v1",
|
||||
signal_grade: Optional[str] = None,
|
||||
signal_strength: Optional[float] = None) -> int:
|
||||
"""
|
||||
Record a signal with market state snapshot and compute forward outcomes.
|
||||
|
||||
Returns the record ID in signal_features.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
# Compute forward outcomes
|
||||
outcomes = self._compute_outcomes(conn, date, entry_price)
|
||||
|
||||
# Build embedding
|
||||
embedding = json.dumps(state.state_embedding())
|
||||
|
||||
record_id = conn.execute("""
|
||||
INSERT INTO signal_features
|
||||
(date, signal_type, signal_version, symbol,
|
||||
regime_version, signal_grade, signal_strength,
|
||||
regime, regime_confidence, regime_maturity_score,
|
||||
market_state_hash, state_embedding,
|
||||
breadth_top20, breadth_top30, breadth_top50,
|
||||
breadth_bucket, breadth_divergence,
|
||||
oi_state, volatility_regime, price_structure_score,
|
||||
entry_price,
|
||||
result_1d, result_3d, result_5d, result_7d, result_14d,
|
||||
max_favorable_excursion, max_adverse_excursion,
|
||||
is_win_7d)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?, ?,
|
||||
?, ?, ?,
|
||||
?,
|
||||
?, ?, ?, ?, ?,
|
||||
?, ?,
|
||||
?)
|
||||
""", (
|
||||
str(date), signal_type, signal_version, state.symbol,
|
||||
state.regime_version, signal_grade, signal_strength,
|
||||
state.regime.value, state.regime_confidence, state.regime_maturity_score,
|
||||
state.market_state_hash, embedding,
|
||||
state.breadth_top20, state.breadth_top30, state.breadth_top50,
|
||||
state.breadth_bucket.value, state.breadth_divergence,
|
||||
state.oi_state.value, state.volatility_regime.value,
|
||||
state.price_structure_score.score,
|
||||
entry_price,
|
||||
outcomes.get("result_1d"), outcomes.get("result_3d"),
|
||||
outcomes.get("result_5d"), outcomes.get("result_7d"),
|
||||
outcomes.get("result_14d"),
|
||||
outcomes.get("mfe"), outcomes.get("mae"),
|
||||
outcomes.get("is_win_7d"),
|
||||
)).lastrowid
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
is_win = outcomes.get("is_win_7d", 0)
|
||||
ret_7d = outcomes.get("result_7d", 0) or 0
|
||||
logger.info(
|
||||
f"Recorded {signal_type} on {date} @ {entry_price:.0f} "
|
||||
f"(regime={state.regime.value}, breadth={state.breadth_bucket.value}, "
|
||||
f"oi={state.oi_state.value}) → 7d={ret_7d:+.1f}%"
|
||||
)
|
||||
return record_id
|
||||
|
||||
def _compute_outcomes(self, conn: sqlite3.Connection, date: Date,
|
||||
entry_price: float) -> dict:
|
||||
"""
|
||||
Compute forward returns, MFE, MAE from OHLCV data.
|
||||
|
||||
Queries future daily bars relative to the signal date.
|
||||
"""
|
||||
# Get future OHLCV data
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, high, low, close FROM ohlcv_daily "
|
||||
"WHERE date > ? AND symbol = 'BTC/USDT:USDT' "
|
||||
"ORDER BY date ASC LIMIT 20",
|
||||
conn, params=(str(date),)
|
||||
)
|
||||
|
||||
if df.empty:
|
||||
return {}
|
||||
|
||||
outcomes = {}
|
||||
entry = entry_price
|
||||
|
||||
# Forward returns
|
||||
for horizon_days, col in [(1, "result_1d"), (3, "result_3d"),
|
||||
(5, "result_5d"), (7, "result_7d"),
|
||||
(14, "result_14d")]:
|
||||
if len(df) >= horizon_days:
|
||||
exit_price = float(df.iloc[horizon_days - 1]["close"])
|
||||
outcomes[col] = round((exit_price - entry) / entry * 100, 2)
|
||||
|
||||
# MFE / MAE
|
||||
if len(df) > 0:
|
||||
highs = df["high"].astype(float).values[:14]
|
||||
lows = df["low"].astype(float).values[:14]
|
||||
outcomes["mfe"] = round((max(highs) - entry) / entry * 100, 2)
|
||||
outcomes["mae"] = round((min(lows) - entry) / entry * 100, 2)
|
||||
|
||||
# is_win_7d
|
||||
outcomes["is_win_7d"] = 1 if outcomes.get("result_7d", 0) > 0 else 0
|
||||
|
||||
return outcomes
|
||||
|
||||
def backfill_signals(self, signals: list[dict]) -> int:
|
||||
"""
|
||||
Backfill multiple signals from historical data.
|
||||
|
||||
Each signal dict:
|
||||
{"date": Date, "signal_type": str, "entry_price": float,
|
||||
"signal_grade": str (optional), "signal_strength": float (optional)}
|
||||
|
||||
This requires the scoring pipeline to have been run for those dates
|
||||
(breadth_daily, ohlcv_daily, derivatives all populated).
|
||||
"""
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
|
||||
detector = RegimeDetector()
|
||||
count = 0
|
||||
|
||||
for sig in signals:
|
||||
target = sig["date"]
|
||||
try:
|
||||
# Compute market state for this date
|
||||
ps = PriceStructureScorer(self.db_path).compute(target)
|
||||
br = BreadthScorer(self.db_path).compute(target)
|
||||
oi = OIMatrixScorer(self.db_path).compute(target)
|
||||
vol = VolatilityRegimeScorer(self.db_path).compute(target)
|
||||
|
||||
regime_result = detector.detect(
|
||||
price_structure_score=ps.score,
|
||||
breadth_score=br.breadth_top50,
|
||||
volatility_regime=vol.vol_regime.value,
|
||||
date=target,
|
||||
)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target,
|
||||
regime=regime_result.regime,
|
||||
regime_confidence=regime_result.confidence,
|
||||
regime_version=regime_result.regime_version,
|
||||
regime_maturity_score=regime_result.maturity_score,
|
||||
breadth_top20=br.breadth_top20,
|
||||
breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50,
|
||||
breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state,
|
||||
volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps,
|
||||
breadth_score=br,
|
||||
oi_matrix_score=oi,
|
||||
volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
|
||||
self.record(
|
||||
date=target,
|
||||
signal_type=sig["signal_type"],
|
||||
entry_price=sig["entry_price"],
|
||||
state=state,
|
||||
signal_grade=sig.get("signal_grade"),
|
||||
signal_strength=sig.get("signal_strength"),
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to backfill {sig['signal_type']} on {target}: {e}")
|
||||
|
||||
return count
|
||||
|
||||
def get_samples(self, signal_type: Optional[str] = None,
|
||||
regime: Optional[str] = None,
|
||||
breadth_bucket: Optional[str] = None,
|
||||
oi_state: Optional[str] = None,
|
||||
volatility_regime: Optional[str] = None,
|
||||
limit: int = 5000) -> list[dict]:
|
||||
"""Query signal_features with optional filters."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
query = "SELECT * FROM signal_features WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if signal_type:
|
||||
query += " AND signal_type = ?"
|
||||
params.append(signal_type)
|
||||
if regime:
|
||||
query += " AND regime = ?"
|
||||
params.append(regime)
|
||||
if breadth_bucket:
|
||||
query += " AND breadth_bucket = ?"
|
||||
params.append(breadth_bucket)
|
||||
if oi_state:
|
||||
query += " AND oi_state = ?"
|
||||
params.append(oi_state)
|
||||
if volatility_regime:
|
||||
query += " AND volatility_regime = ?"
|
||||
params.append(volatility_regime)
|
||||
|
||||
query += " ORDER BY date DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def count_samples(self) -> dict:
|
||||
"""Count signal_features by signal_type and regime."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
rows = conn.execute("""
|
||||
SELECT signal_type, regime, COUNT(*) as cnt
|
||||
FROM signal_features
|
||||
GROUP BY signal_type, regime
|
||||
ORDER BY signal_type, regime
|
||||
""").fetchall()
|
||||
conn.close()
|
||||
return {f"{r[0]}/{r[1]}": r[2] for r in rows}
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Data fetchers — L0 raw data acquisition."""
|
||||
from .base import BaseFetcher
|
||||
from .ohlcv import OHLCVFetcher
|
||||
from .breadth import BreadthFetcher
|
||||
from .derivatives import DerivativesFetcher
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
fetchers/base.py — Abstract base class for all macro data fetchers.
|
||||
|
||||
Provides retry logic, rate limiting, and a common interface.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
|
||||
|
||||
class BaseFetcher(ABC):
|
||||
"""Abstract base for all macro data fetchers."""
|
||||
|
||||
def __init__(self, timeout: int = 30, max_retries: int = 3):
|
||||
self.timeout = timeout
|
||||
self.max_retries = max_retries
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
def _get(self, url: str, params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None) -> dict:
|
||||
"""GET with retry and exponential backoff."""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException as e:
|
||||
wait = 2 ** attempt
|
||||
self.logger.warning(
|
||||
f"Request failed (attempt {attempt+1}/{self.max_retries}): {e}. "
|
||||
f"Retrying in {wait}s"
|
||||
)
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(wait)
|
||||
else:
|
||||
raise
|
||||
|
||||
def _get_raw(self, url: str, params: Optional[dict] = None,
|
||||
headers: Optional[dict] = None) -> bytes:
|
||||
"""GET raw bytes with retry (for non-JSON endpoints)."""
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
resp = requests.get(
|
||||
url, params=params, headers=headers, timeout=self.timeout
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except requests.RequestException as e:
|
||||
wait = 2 ** attempt
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(wait)
|
||||
else:
|
||||
raise
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
|
||||
"""Fetch raw data. Returns list of record dicts."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def store(self, db_path: str, records: list[dict]) -> int:
|
||||
"""Store raw records into SQLite. Returns count of new rows."""
|
||||
...
|
||||
@@ -1,189 +0,0 @@
|
||||
"""
|
||||
fetchers/breadth.py — Fetches TOP50 OHLCV and computes market breadth metrics.
|
||||
|
||||
Multi-tier: Top20 / Top30 / Top50 for advance/decline, EMA20%, new highs, BTC.D.
|
||||
"""
|
||||
|
||||
from datetime import date as Date, datetime
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher
|
||||
from config import config
|
||||
|
||||
|
||||
class BreadthFetcher(BaseFetcher):
|
||||
"""Fetches TOP50 coin OHLCV data and computes breadth metrics."""
|
||||
|
||||
def __init__(self, provider_url: Optional[str] = None):
|
||||
super().__init__(timeout=60, max_retries=3)
|
||||
self.provider_url = provider_url or config.provider_url
|
||||
self.symbols = config.top50_symbols
|
||||
self.ema_period = config.breadth_ema_period
|
||||
self.new_high_window = config.breadth_new_high_window
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch(self, target_date: Optional[Date] = None) -> dict:
|
||||
"""
|
||||
Fetch daily OHLCV for all TOP50 symbols and compute breadth.
|
||||
|
||||
Returns a dict suitable for storing in breadth_daily table.
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = Date.today()
|
||||
|
||||
# Fetch last 60 days of daily data for each symbol to compute EMAs and new highs
|
||||
all_data = {}
|
||||
for symbol in self.symbols:
|
||||
try:
|
||||
df = self._fetch_symbol(symbol)
|
||||
if df is not None and not df.empty:
|
||||
all_data[symbol] = df
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to fetch {symbol}: {e}")
|
||||
|
||||
if not all_data:
|
||||
self.logger.error("No symbol data fetched for breadth")
|
||||
return {}
|
||||
|
||||
# Compute breadth metrics for the target date
|
||||
breadth = self._compute_breadth(all_data, target_date)
|
||||
return breadth
|
||||
|
||||
def _fetch_symbol(self, symbol: str) -> Optional[pd.DataFrame]:
|
||||
"""Fetch daily OHLCV for a single symbol."""
|
||||
url = f"{self.provider_url}/api/candles"
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"tf": "1d",
|
||||
"limit": 100,
|
||||
}
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not data:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df["date"] = df["timestamp"].dt.date
|
||||
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
|
||||
df["close"] = df["close"].astype(float)
|
||||
df["ema20"] = df["close"].ewm(span=self.ema_period, adjust=False).mean()
|
||||
return df
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _compute_breadth(self, all_data: dict, target_date: Date) -> dict:
|
||||
"""Compute breadth metrics for a specific date across all symbols."""
|
||||
total = len(all_data)
|
||||
|
||||
advances_50 = declines_50 = 0
|
||||
above_ema20_50 = 0
|
||||
new_highs_50 = 0
|
||||
advances_30 = declines_30 = 0
|
||||
above_ema20_30 = 0
|
||||
new_highs_30 = 0
|
||||
advances_20 = declines_20 = 0
|
||||
above_ema20_20 = 0
|
||||
new_highs_20 = 0
|
||||
|
||||
for i, (symbol, df) in enumerate(all_data.items()):
|
||||
# Get data for target date
|
||||
df["date_str"] = df["date"].astype(str)
|
||||
target_str = str(target_date)
|
||||
idx = df[df["date_str"] == target_str].index
|
||||
|
||||
if len(idx) == 0:
|
||||
continue
|
||||
|
||||
row_idx = idx[0]
|
||||
if row_idx < 1:
|
||||
continue
|
||||
|
||||
current_close = df.loc[row_idx, "close"]
|
||||
prev_close = df.loc[row_idx - 1, "close"]
|
||||
|
||||
# Advance/Decline
|
||||
if current_close > prev_close:
|
||||
if i < 50: advances_50 += 1
|
||||
if i < 30: advances_30 += 1
|
||||
if i < 20: advances_20 += 1
|
||||
elif current_close < prev_close:
|
||||
if i < 50: declines_50 += 1
|
||||
if i < 30: declines_30 += 1
|
||||
if i < 20: declines_20 += 1
|
||||
|
||||
# Above EMA20
|
||||
ema20_val = df.loc[row_idx, "ema20"]
|
||||
if not pd.isna(ema20_val) and current_close > ema20_val:
|
||||
if i < 50: above_ema20_50 += 1
|
||||
if i < 30: above_ema20_30 += 1
|
||||
if i < 20: above_ema20_20 += 1
|
||||
|
||||
# New 20-day highs
|
||||
lookback_start = max(0, row_idx - self.new_high_window)
|
||||
recent_highs = df.loc[lookback_start:row_idx - 1, "high"].astype(float)
|
||||
current_high = df.loc[row_idx, "high"]
|
||||
if len(recent_highs) > 0 and float(current_high) > recent_highs.max():
|
||||
if i < 50: new_highs_50 += 1
|
||||
if i < 30: new_highs_30 += 1
|
||||
if i < 20: new_highs_20 += 1
|
||||
|
||||
return {
|
||||
"date": str(target_date),
|
||||
"total_tracked": total,
|
||||
"advance_top50": advances_50,
|
||||
"decline_top50": declines_50,
|
||||
"above_ema20_top50": above_ema20_50,
|
||||
"new_highs_20d_top50": new_highs_50,
|
||||
"advance_top30": advances_30,
|
||||
"advance_top20": advances_20,
|
||||
"above_ema20_top30": above_ema20_30,
|
||||
"above_ema20_top20": above_ema20_20,
|
||||
"new_highs_20d_top30": new_highs_30,
|
||||
"new_highs_20d_top20": new_highs_20,
|
||||
"btc_dominance": None, # Reserved for Coinglass API integration
|
||||
}
|
||||
|
||||
def store(self, db_path: Optional[str] = None, record: Optional[dict] = None) -> int:
|
||||
"""Store a breadth record into SQLite. Returns 1 if inserted/updated."""
|
||||
import sqlite3
|
||||
db_path = db_path or config.db_path
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
if record is None:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO breadth_daily
|
||||
(date, total_tracked,
|
||||
advance_top50, decline_top50, above_ema20_top50, new_highs_20d_top50,
|
||||
advance_top30, advance_top20,
|
||||
above_ema20_top30, above_ema20_top20,
|
||||
new_highs_20d_top30, new_highs_20d_top20,
|
||||
btc_dominance)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
record["date"], record.get("total_tracked", 50),
|
||||
record.get("advance_top50", 0), record.get("decline_top50", 0),
|
||||
record.get("above_ema20_top50", 0), record.get("new_highs_20d_top50", 0),
|
||||
record.get("advance_top30", 0), record.get("advance_top20", 0),
|
||||
record.get("above_ema20_top30", 0), record.get("above_ema20_top20", 0),
|
||||
record.get("new_highs_20d_top30", 0), record.get("new_highs_20d_top20", 0),
|
||||
record.get("btc_dominance"),
|
||||
))
|
||||
conn.commit()
|
||||
return 1
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to store breadth: {e}")
|
||||
return 0
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
fetchers/derivatives.py — Fetches derivatives data from data_provider API.
|
||||
|
||||
Clean consumer: no direct ccxt dependency. Just HTTP GET /api/derivatives.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher
|
||||
from config import config
|
||||
|
||||
|
||||
class DerivativesFetcher(BaseFetcher):
|
||||
"""Fetches derivatives snapshot from data_provider /api/derivatives."""
|
||||
|
||||
def __init__(self, provider_url: Optional[str] = None):
|
||||
super().__init__(timeout=15, max_retries=3)
|
||||
self.provider_url = provider_url or config.provider_url
|
||||
|
||||
def fetch(self, target_date: Optional[Date] = None) -> list[dict]:
|
||||
"""Fetch derivatives data. Returns list with one record dict."""
|
||||
url = f"{self.provider_url}/api/derivatives"
|
||||
params = {"symbol": config.btc_symbol}
|
||||
try:
|
||||
data = self._get(url, params=params)
|
||||
record = {
|
||||
"date": str(target_date or Date.today()),
|
||||
"symbol": config.btc_symbol,
|
||||
"funding_rate": data.get("funding_rate"),
|
||||
"open_interest": data.get("open_interest"),
|
||||
"oi_24h_change_pct": data.get("oi_change_pct"),
|
||||
"basis_annualised_pct": data.get("basis"),
|
||||
"source": "data_provider",
|
||||
}
|
||||
return [record]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def store(self, db_path: Optional[str] = None, records: Optional[list[dict]] = None) -> int:
|
||||
"""Store derivatives records into SQLite."""
|
||||
import sqlite3
|
||||
db_path = db_path or config.db_path
|
||||
records = records or []
|
||||
conn = sqlite3.connect(db_path)
|
||||
count = 0
|
||||
for r in records:
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO derivatives
|
||||
(date, symbol, funding_rate, open_interest, oi_24h_change_pct,
|
||||
long_liquidations, short_liquidations, basis_annualised_pct)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?)
|
||||
""", (
|
||||
r["date"], r.get("symbol", config.btc_symbol),
|
||||
r.get("funding_rate"), r.get("open_interest"),
|
||||
r.get("oi_24h_change_pct"), r.get("basis_annualised_pct"),
|
||||
))
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return count
|
||||
@@ -1,157 +0,0 @@
|
||||
"""
|
||||
fetchers/ohlcv.py — Fetches BTC daily OHLCV from the existing data_provider service.
|
||||
|
||||
Also pre-computes EMA20/60/120, ATR(14), BB width, ADX(14).
|
||||
"""
|
||||
|
||||
from datetime import date as Date, datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from .base import BaseFetcher
|
||||
from config import config
|
||||
|
||||
|
||||
class OHLCVFetcher(BaseFetcher):
|
||||
"""Fetches BTC daily K-line data from data_provider API."""
|
||||
|
||||
def __init__(self, provider_url: Optional[str] = None):
|
||||
super().__init__(timeout=30, max_retries=3)
|
||||
self.provider_url = provider_url or config.provider_url
|
||||
self.symbol = config.btc_symbol
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def fetch(self, target_date: Optional[Date] = None) -> pd.DataFrame:
|
||||
"""
|
||||
Fetch daily OHLCV for BTC. Returns DataFrame with computed indicators.
|
||||
|
||||
Fetches enough history (200 bars) to compute EMAs/ATR/BB/ADX accurately.
|
||||
"""
|
||||
url = f"{self.provider_url}/api/candles"
|
||||
params = {
|
||||
"symbol": self.symbol,
|
||||
"tf": "1d",
|
||||
"limit": 200,
|
||||
}
|
||||
resp = requests.get(url, params=params, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if not data:
|
||||
self.logger.warning("OHLCV API returned empty data")
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df["date"] = df["timestamp"].dt.date
|
||||
df = df.drop_duplicates(subset="date").sort_values("date").reset_index(drop=True)
|
||||
|
||||
# Rename columns to match expected format
|
||||
df = df.rename(columns={
|
||||
"open": "open", "high": "high", "low": "low", "close": "close",
|
||||
"volume": "volume",
|
||||
})
|
||||
|
||||
# Compute indicators
|
||||
df = self._add_indicators(df)
|
||||
|
||||
return df
|
||||
|
||||
def _add_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add EMA, ATR, BB, ADX indicators."""
|
||||
close = df["close"].astype(float)
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
|
||||
# EMAs
|
||||
df["ema20"] = close.ewm(span=20, adjust=False).mean()
|
||||
df["ema60"] = close.ewm(span=60, adjust=False).mean()
|
||||
df["ema120"] = close.ewm(span=120, adjust=False).mean()
|
||||
|
||||
# ATR(14)
|
||||
tr1 = high - low
|
||||
tr2 = (high - close.shift(1)).abs()
|
||||
tr3 = (low - close.shift(1)).abs()
|
||||
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||
df["atr_14"] = tr.rolling(14).mean()
|
||||
|
||||
# Bollinger Bands width
|
||||
sma20 = close.rolling(20).mean()
|
||||
std20 = close.rolling(20).std()
|
||||
df["bb_width"] = (2 * std20) / sma20 * 100 # as percentage
|
||||
|
||||
# ADX(14)
|
||||
df["adx_14"] = self._compute_adx(df, period=14)
|
||||
|
||||
return df
|
||||
|
||||
@staticmethod
|
||||
def _compute_adx(df: pd.DataFrame, period: int = 14) -> pd.Series:
|
||||
"""Compute ADX from OHLC data."""
|
||||
high = df["high"].astype(float)
|
||||
low = df["low"].astype(float)
|
||||
close = df["close"].astype(float)
|
||||
|
||||
plus_dm = high.diff()
|
||||
minus_dm = low.diff().abs() * -1
|
||||
plus_dm = plus_dm.where(plus_dm > 0, 0)
|
||||
minus_dm = minus_dm.where(minus_dm < 0, 0).abs()
|
||||
|
||||
tr1 = high - low
|
||||
tr2 = (high - close.shift(1)).abs()
|
||||
tr3 = (low - close.shift(1)).abs()
|
||||
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
||||
|
||||
atr = tr.rolling(period).mean()
|
||||
plus_di = 100 * (plus_dm.rolling(period).mean() / atr)
|
||||
minus_di = 100 * (minus_dm.rolling(period).mean() / atr)
|
||||
|
||||
dx = (abs(plus_di - minus_di) / (plus_di + minus_di)) * 100
|
||||
adx = dx.rolling(period).mean()
|
||||
return adx
|
||||
|
||||
def store(self, db_path: str, records: list[dict]) -> int:
|
||||
"""Store OHLCV records into SQLite. Not used directly — see store_df."""
|
||||
return 0
|
||||
|
||||
def store_df(self, df: pd.DataFrame, db_path: Optional[str] = None) -> int:
|
||||
"""Store the DataFrame into the ohlcv_daily table."""
|
||||
import sqlite3
|
||||
db_path = db_path or config.db_path
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
count = 0
|
||||
for _, row in df.iterrows():
|
||||
if pd.isna(row.get("date")):
|
||||
continue
|
||||
date_str = str(row["date"])
|
||||
try:
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO ohlcv_daily
|
||||
(date, symbol, open, high, low, close, volume,
|
||||
ema20, ema60, ema120, atr_14, bb_width, adx_14)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
date_str, self.symbol,
|
||||
float(row["open"]), float(row["high"]),
|
||||
float(row["low"]), float(row["close"]),
|
||||
float(row.get("volume", 0)),
|
||||
float(row["ema20"]) if not pd.isna(row.get("ema20")) else None,
|
||||
float(row["ema60"]) if not pd.isna(row.get("ema60")) else None,
|
||||
float(row["ema120"]) if not pd.isna(row.get("ema120")) else None,
|
||||
float(row["atr_14"]) if not pd.isna(row.get("atr_14")) else None,
|
||||
float(row["bb_width"]) if not pd.isna(row.get("bb_width")) else None,
|
||||
float(row["adx_14"]) if not pd.isna(row.get("adx_14")) else None,
|
||||
))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Skip row {date_str}: {e}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.logger.info(f"Stored {count} OHLCV rows")
|
||||
return count
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
main.py — ChanMacro entry point.
|
||||
|
||||
CLI: python main.py fetch|score|regime|serve
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure package root is on path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,370 +0,0 @@
|
||||
"""
|
||||
models.py — Pydantic v2 models and enums for ChanMacro.
|
||||
|
||||
All market state types, factor scores, and database record models.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Shared validators
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_date(v):
|
||||
"""Reusable date-string parser for field_validator."""
|
||||
if isinstance(v, str):
|
||||
return Date.fromisoformat(v)
|
||||
return v
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Enums
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class MarketRegime(str, Enum):
|
||||
"""V1: 3-state regime (factor-locked: Price + Breadth + Vol)."""
|
||||
TREND = "TREND"
|
||||
RANGE = "RANGE"
|
||||
PANIC = "PANIC"
|
||||
|
||||
|
||||
class OIState(str, Enum):
|
||||
"""Discrete OI × Price state machine. NOT compressed into a score."""
|
||||
NEW_LONGS = "New Longs"
|
||||
SHORT_COVERING = "Short Covering"
|
||||
NEW_SHORTS = "New Shorts"
|
||||
LONG_EXIT = "Long Exit"
|
||||
NEUTRAL = "Neutral"
|
||||
|
||||
|
||||
class BreadthBucket(str, Enum):
|
||||
"""Quantile-based breadth buckets — always have samples regardless of cycle."""
|
||||
EXTREME = "EXTREME"
|
||||
STRONG = "STRONG"
|
||||
NORMAL = "NORMAL"
|
||||
WEAK = "WEAK"
|
||||
PANIC = "PANIC"
|
||||
|
||||
|
||||
class VolRegime(str, Enum):
|
||||
"""Volatility regime classification."""
|
||||
LOW_VOL = "LOW_VOL"
|
||||
NORMAL_VOL = "NORMAL_VOL"
|
||||
HIGH_VOL = "HIGH_VOL"
|
||||
EXPLOSIVE_VOL = "EXPLOSIVE_VOL"
|
||||
|
||||
|
||||
class MacroDirection(str, Enum):
|
||||
BULLISH = "bullish"
|
||||
NEUTRAL = "neutral"
|
||||
BEARISH = "bearish"
|
||||
|
||||
|
||||
class MarketEmotion(str, Enum):
|
||||
EXTREME_FEAR = "Extreme Fear"
|
||||
FEAR = "Fear"
|
||||
NEUTRAL = "Neutral"
|
||||
GREED = "Greed"
|
||||
EXTREME_GREED = "Extreme Greed"
|
||||
|
||||
|
||||
class FlowState(str, Enum):
|
||||
STRONG_INFLOW = "Strong Inflow"
|
||||
INFLOW = "Inflow"
|
||||
NEUTRAL = "Neutral"
|
||||
OUTFLOW = "Outflow"
|
||||
STRONG_OUTFLOW = "Strong Outflow"
|
||||
|
||||
|
||||
class CapitalState(str, Enum):
|
||||
ENTERING = "Entering"
|
||||
STABLE = "Stable"
|
||||
EXITING = "Exiting"
|
||||
|
||||
|
||||
class SufficiencyLevel(str, Enum):
|
||||
HIGH = "HIGH"
|
||||
MEDIUM = "MEDIUM"
|
||||
LOW = "LOW"
|
||||
INSUFFICIENT = "INSUFFICIENT"
|
||||
|
||||
|
||||
class SignalGrade(str, Enum):
|
||||
A = "A"
|
||||
B = "B"
|
||||
C = "C"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# L0: Raw Data Models
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class OHLCVDaily(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
symbol: str
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: float
|
||||
ema20: Optional[float] = None
|
||||
ema60: Optional[float] = None
|
||||
ema120: Optional[float] = None
|
||||
atr_14: Optional[float] = None
|
||||
bb_width: Optional[float] = None
|
||||
adx_14: Optional[float] = None
|
||||
|
||||
|
||||
class BreadthRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
total_tracked: int = 50
|
||||
advance_top50: int = 0
|
||||
decline_top50: int = 0
|
||||
above_ema20_top50: int = 0
|
||||
new_highs_20d_top50: int = 0
|
||||
btc_dominance: Optional[float] = None
|
||||
advance_top20: int = 0
|
||||
advance_top30: int = 0
|
||||
above_ema20_top20: int = 0
|
||||
above_ema20_top30: int = 0
|
||||
new_highs_20d_top20: int = 0
|
||||
new_highs_20d_top30: int = 0
|
||||
|
||||
|
||||
class DerivativesRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
symbol: str = "BTC/USDT:USDT"
|
||||
funding_rate: Optional[float] = None
|
||||
open_interest: Optional[float] = None
|
||||
oi_24h_change_pct: Optional[float] = None
|
||||
long_liquidations: Optional[float] = None
|
||||
short_liquidations: Optional[float] = None
|
||||
basis_annualised_pct: Optional[float] = None
|
||||
source: str = "binance"
|
||||
|
||||
|
||||
class ETFFlowRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
product: str
|
||||
net_flow_million: float
|
||||
price: Optional[float] = None
|
||||
source: str = "farside"
|
||||
|
||||
|
||||
class StablecoinSupplyRecord(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
token: str
|
||||
chain: str = "all"
|
||||
supply: float
|
||||
source: str = "defillama"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# L1: Factor Score Models
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class FactorScore(BaseModel):
|
||||
"""Single factor scoring output."""
|
||||
name: str = ""
|
||||
score: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
label: str = ""
|
||||
direction: MacroDirection = MacroDirection.NEUTRAL
|
||||
sub_scores: dict = Field(default_factory=dict)
|
||||
narrative: str = ""
|
||||
|
||||
|
||||
class PriceStructureScore(FactorScore):
|
||||
"""Price Structure — 3 sub-dimensions."""
|
||||
trend_strength: float = 0.0
|
||||
volatility_compression: float = 0.0
|
||||
momentum: float = 0.0
|
||||
|
||||
|
||||
class BreadthScore(FactorScore):
|
||||
"""Breadth — multi-tier market diffusion."""
|
||||
breadth_top20: float = 0.0
|
||||
breadth_top30: float = 0.0
|
||||
breadth_top50: float = 0.0
|
||||
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
|
||||
breadth_divergence: float = 0.0
|
||||
advance_pct_top50: float = 0.0
|
||||
above_ema20_pct_top50: float = 0.0
|
||||
new_highs_top50: int = 0
|
||||
btc_dominance_7d_chg: Optional[float] = None
|
||||
|
||||
|
||||
class OIMatrixScore(FactorScore):
|
||||
"""OI Matrix — discrete state + continuous score."""
|
||||
oi_state: OIState = OIState.NEUTRAL
|
||||
price_change_pct: float = 0.0
|
||||
oi_change_pct: float = 0.0
|
||||
|
||||
|
||||
class VolatilityRegimeScore(FactorScore):
|
||||
"""Volatility regime classification."""
|
||||
vol_regime: VolRegime = VolRegime.NORMAL_VOL
|
||||
atr_pct: float = 0.0
|
||||
hv_ratio: float = 1.0
|
||||
bb_width_ratio: float = 1.0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# L4: Market State Vector (the final product)
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class MarketStateVector(BaseModel):
|
||||
"""L4: Complete market state description. NOT compressed into one number."""
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
|
||||
date: Date
|
||||
symbol: str = "BTC/USDT:USDT"
|
||||
|
||||
regime: MarketRegime
|
||||
regime_confidence: float = Field(ge=0.0, le=1.0)
|
||||
regime_version: str
|
||||
regime_maturity_score: float = Field(ge=0.0, le=100.0, default=50.0)
|
||||
|
||||
breadth_top20: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
breadth_top30: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
breadth_top50: float = Field(default=50.0, ge=0.0, le=100.0)
|
||||
breadth_bucket: BreadthBucket = BreadthBucket.NORMAL
|
||||
breadth_divergence: float = 0.0
|
||||
|
||||
oi_state: OIState = OIState.NEUTRAL
|
||||
volatility_regime: VolRegime = VolRegime.NORMAL_VOL
|
||||
|
||||
price_structure_score: FactorScore = Field(default_factory=FactorScore)
|
||||
breadth_score: BreadthScore = Field(default_factory=BreadthScore)
|
||||
oi_matrix_score: OIMatrixScore = Field(default_factory=OIMatrixScore)
|
||||
volatility_regime_score: VolatilityRegimeScore = Field(default_factory=VolatilityRegimeScore)
|
||||
|
||||
market_state_hash: str = ""
|
||||
|
||||
def compute_hash(self) -> str:
|
||||
import hashlib
|
||||
key = f"{self.regime.value}|{self.breadth_bucket.value}|{self.oi_state.value}|{self.volatility_regime.value}"
|
||||
return hashlib.md5(key.encode()).hexdigest()[:12]
|
||||
|
||||
def state_embedding(self) -> list[float]:
|
||||
return [
|
||||
self.breadth_top20,
|
||||
self.breadth_top30,
|
||||
self.breadth_top50,
|
||||
self.regime_maturity_score,
|
||||
self.price_structure_score.score,
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Factor Contribution
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class FactorContribution(BaseModel):
|
||||
"""How much a factor contributed to the overall score."""
|
||||
factor: str
|
||||
raw_score: float
|
||||
weight: float
|
||||
impact: float
|
||||
direction: str # 'bullish' / 'bearish' / 'neutral'
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Regime Result
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class RegimeResult(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
regime: MarketRegime
|
||||
confidence: float
|
||||
regime_version: str
|
||||
maturity_score: float
|
||||
all_scores: dict = Field(default_factory=dict)
|
||||
prior_regime: Optional[MarketRegime] = None
|
||||
confirmation_days: int = 0
|
||||
|
||||
|
||||
class SignalFeatureRecord(BaseModel):
|
||||
"""A single signal → market state → outcome record."""
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
signal_type: str
|
||||
signal_version: str = "b3_v1"
|
||||
symbol: str = "BTC/USDT:USDT"
|
||||
|
||||
regime_version: str
|
||||
signal_grade: Optional[SignalGrade] = None
|
||||
signal_strength: Optional[float] = None
|
||||
|
||||
regime: MarketRegime
|
||||
regime_confidence: float
|
||||
regime_maturity_score: float
|
||||
market_state_hash: str
|
||||
state_embedding: str = "[]"
|
||||
breadth_top20: float
|
||||
breadth_top30: float
|
||||
breadth_top50: float
|
||||
breadth_bucket: BreadthBucket
|
||||
breadth_divergence: float
|
||||
oi_state: OIState
|
||||
volatility_regime: VolRegime
|
||||
price_structure_score: float
|
||||
|
||||
chan_trend_direction: Optional[str] = None
|
||||
chan_pivot_count: Optional[int] = None
|
||||
chan_divergence_type: Optional[str] = None
|
||||
|
||||
entry_price: Optional[float] = None
|
||||
result_1d: Optional[float] = None
|
||||
result_3d: Optional[float] = None
|
||||
result_5d: Optional[float] = None
|
||||
result_7d: Optional[float] = None
|
||||
result_14d: Optional[float] = None
|
||||
max_favorable_excursion: Optional[float] = None
|
||||
max_adverse_excursion: Optional[float] = None
|
||||
is_win_7d: Optional[int] = None
|
||||
|
||||
|
||||
class ExpectancyLayer(BaseModel):
|
||||
name: str
|
||||
posterior_winrate: float
|
||||
raw_winrate: Optional[float] = None
|
||||
samples: int = 0
|
||||
effective_samples: float = 0.0
|
||||
avg_return: Optional[float] = None
|
||||
|
||||
|
||||
class ExpectancyReport(BaseModel):
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
signal_type: str
|
||||
date: Date
|
||||
layers: list[ExpectancyLayer] = Field(default_factory=list)
|
||||
final_estimate: float
|
||||
sufficiency: SufficiencyLevel = SufficiencyLevel.INSUFFICIENT
|
||||
prior_strength: int = 40
|
||||
half_life_days: int = 180
|
||||
source: str = "bayesian"
|
||||
|
||||
avg_return_7d: Optional[float] = None
|
||||
profit_factor: Optional[float] = None
|
||||
max_adverse_excursion: Optional[float] = None
|
||||
|
||||
|
||||
class DailyOutput(BaseModel):
|
||||
"""Final daily output: Market State + Expectancy."""
|
||||
_parse_date = field_validator("date", mode="before")(_parse_date)
|
||||
date: Date
|
||||
market_state: MarketStateVector
|
||||
expectancy: dict[str, ExpectancyReport] = Field(default_factory=dict)
|
||||
ai_report_en: Optional[str] = None
|
||||
ai_report_zh: Optional[str] = None
|
||||
@@ -1,213 +0,0 @@
|
||||
"""
|
||||
regime_detector.py — Market regime detection (V1: 3 states).
|
||||
|
||||
★ FACTOR-LOCKED: Regime = f(Price Structure, Breadth, Volatility) — forever.
|
||||
Fear, Liquidation, ETF, Funding are Context, NOT regime inputs.
|
||||
Adding new factors MUST NOT change regime definition.
|
||||
|
||||
★ VERSIONED: regime_version = 'v1_price_breadth_vol'.
|
||||
Weight changes → new version. Multiple versions coexist.
|
||||
Query: WHERE regime_version = 'v1_price_breadth_vol'.
|
||||
|
||||
★ CONFIDENCE-BASED: Each regime gets a continuous score. Highest wins.
|
||||
No hard thresholds (prevents boundary oscillation).
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
from collections import deque
|
||||
|
||||
from models import MarketRegime, RegimeResult
|
||||
from config import config
|
||||
|
||||
|
||||
class RegimeDetector:
|
||||
"""
|
||||
Detects market regime from Price + Breadth + Vol.
|
||||
|
||||
V1: 3 regimes (TREND / RANGE / PANIC)
|
||||
V2+: Can split TREND→TREND_UP/TREND_DOWN/EUPHORIA when samples > 500/regime.
|
||||
"""
|
||||
|
||||
def __init__(self, regime_version: Optional[str] = None):
|
||||
self.version = regime_version or config.regime_version
|
||||
self.w_price = config.regime_w_price
|
||||
self.w_breadth = config.regime_w_breadth
|
||||
self.w_vol = config.regime_w_vol
|
||||
self.panic_w_anti_trend = config.regime_panic_w_anti_trend
|
||||
self.panic_w_vol_extreme = config.regime_panic_w_vol_extreme
|
||||
|
||||
# State persistence
|
||||
self._current_regime: Optional[MarketRegime] = None
|
||||
self._pending_regime: Optional[MarketRegime] = None
|
||||
self._confirmation_count: int = 0
|
||||
self._consecutive_days: int = 0
|
||||
self._regime_history: deque = deque(maxlen=100)
|
||||
|
||||
# Confirmation: 2 days minimum
|
||||
self.MIN_CONFIRMATION = 2
|
||||
|
||||
def load_state(self, db_path: str):
|
||||
"""Restore regime state from the most recent regime_history record."""
|
||||
import sqlite3
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT regime, confidence, confirmation_days, maturity_score "
|
||||
"FROM regime_history ORDER BY date DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
regime_str = row["regime"]
|
||||
if regime_str in ("TREND", "RANGE", "PANIC"):
|
||||
self._current_regime = MarketRegime(regime_str)
|
||||
self._consecutive_days = row["confirmation_days"] or 1
|
||||
except Exception:
|
||||
pass # DB not initialized yet, use defaults
|
||||
|
||||
def detect(self, price_structure_score: float, breadth_score: float,
|
||||
volatility_regime: str, date: Date) -> RegimeResult:
|
||||
"""
|
||||
Detect regime from the 3 locked factors.
|
||||
|
||||
Args:
|
||||
price_structure_score: 0-100 from PriceStructureScorer
|
||||
breadth_score: 0-100 from BreadthScorer
|
||||
volatility_regime: 'LOW_VOL'/'NORMAL_VOL'/'HIGH_VOL'/'EXPLOSIVE_VOL'
|
||||
date: Target date
|
||||
"""
|
||||
# ── Compute regime scores ────────────────────────
|
||||
# TREND: strong price + strong breadth + non-extreme vol
|
||||
trend_score = (
|
||||
price_structure_score * self.w_price +
|
||||
breadth_score * self.w_breadth +
|
||||
self._vol_to_trend(volatility_regime) * self.w_vol
|
||||
)
|
||||
|
||||
# RANGE: neutral price + neutral breadth + low vol
|
||||
# Score how "range-like" each dimension is
|
||||
price_neutral = 60 - abs(price_structure_score - 50)
|
||||
breadth_neutral = 60 - abs(breadth_score - 50)
|
||||
vol_neutral = 80 if volatility_regime in ("LOW_VOL", "NORMAL_VOL") else 30
|
||||
range_score = (
|
||||
price_neutral * 0.40 +
|
||||
breadth_neutral * 0.40 +
|
||||
vol_neutral * 0.20
|
||||
)
|
||||
|
||||
# PANIC: very weak trend + extreme vol (NO Fear/Liquidation!)
|
||||
anti_trend = 100 - trend_score
|
||||
vol_extreme = 100 if volatility_regime == "EXPLOSIVE_VOL" else (
|
||||
60 if volatility_regime == "HIGH_VOL" else 20
|
||||
)
|
||||
panic_score = (
|
||||
anti_trend * self.panic_w_anti_trend +
|
||||
vol_extreme * self.panic_w_vol_extreme
|
||||
)
|
||||
|
||||
scores = {
|
||||
MarketRegime.TREND: round(trend_score, 1),
|
||||
MarketRegime.RANGE: round(range_score, 1),
|
||||
MarketRegime.PANIC: round(panic_score, 1),
|
||||
}
|
||||
|
||||
best_regime = max(scores, key=scores.get)
|
||||
|
||||
# ── Persistence check ────────────────────────────
|
||||
prior_regime = self._current_regime
|
||||
|
||||
if best_regime == self._current_regime:
|
||||
self._consecutive_days += 1
|
||||
self._pending_regime = None
|
||||
self._confirmation_count = 0
|
||||
elif best_regime == self._pending_regime:
|
||||
self._confirmation_count += 1
|
||||
if self._confirmation_count >= self.MIN_CONFIRMATION:
|
||||
# Transition confirmed
|
||||
prior_regime = self._current_regime
|
||||
self._current_regime = best_regime
|
||||
self._consecutive_days = self.MIN_CONFIRMATION
|
||||
self._pending_regime = None
|
||||
self._confirmation_count = 0
|
||||
else:
|
||||
self._pending_regime = best_regime
|
||||
self._confirmation_count = 1
|
||||
|
||||
# Fallback: if no current regime yet (first run)
|
||||
if self._current_regime is None:
|
||||
self._current_regime = best_regime
|
||||
self._consecutive_days = 1
|
||||
|
||||
# ── Confidence: for the CONFIRMED regime, not the raw best ──
|
||||
confirmed_regime = self._current_regime
|
||||
confidence = scores[confirmed_regime] / 100.0
|
||||
|
||||
# ── Maturity ─────────────────────────────────────
|
||||
maturity = self._compute_maturity(
|
||||
trend_score, breadth_score, volatility_regime
|
||||
)
|
||||
|
||||
# Track history
|
||||
self._regime_history.append({
|
||||
"date": date,
|
||||
"regime": confirmed_regime.value,
|
||||
"confidence": round(confidence, 3),
|
||||
})
|
||||
|
||||
return RegimeResult(
|
||||
date=date,
|
||||
regime=confirmed_regime,
|
||||
confidence=round(confidence, 3),
|
||||
prior_regime=prior_regime,
|
||||
regime_version=self.version,
|
||||
maturity_score=round(maturity, 1),
|
||||
all_scores={k.value: v for k, v in scores.items()},
|
||||
confirmation_days=self._consecutive_days,
|
||||
)
|
||||
|
||||
@property
|
||||
def current_regime(self) -> Optional[MarketRegime]:
|
||||
return self._current_regime
|
||||
|
||||
@property
|
||||
def pending_regime(self) -> Optional[MarketRegime]:
|
||||
return self._pending_regime
|
||||
|
||||
@property
|
||||
def confirmation_progress(self) -> tuple[int, int]:
|
||||
"""(confirmed_days, required_days) for pending transition."""
|
||||
return (self._confirmation_count, self.MIN_CONFIRMATION)
|
||||
|
||||
@staticmethod
|
||||
def _vol_to_trend(vol_regime: str) -> float:
|
||||
"""Convert volatility regime to trend-contributing score."""
|
||||
mapping = {
|
||||
"LOW_VOL": 50, # Low vol: neutral for trend
|
||||
"NORMAL_VOL": 70, # Normal vol: good for trend
|
||||
"HIGH_VOL": 60, # High vol: trending but risky
|
||||
"EXPLOSIVE_VOL": 30, # Explosive: anti-trend
|
||||
}
|
||||
return mapping.get(vol_regime, 50)
|
||||
|
||||
@staticmethod
|
||||
def _compute_maturity(trend_score: float, breadth_score: float,
|
||||
vol_regime: str) -> float:
|
||||
"""
|
||||
Compute regime maturity: 0-100 continuous.
|
||||
0-30: EMERGING (trend accelerating, breadth expanding)
|
||||
30-70: CONFIRMED (stable)
|
||||
70-100: EXHAUSTING (decelerating, vol abnormal)
|
||||
"""
|
||||
# Trend strength contribution
|
||||
trend_contrib = trend_score * 0.50
|
||||
|
||||
# Breadth contribution
|
||||
breadth_contrib = breadth_score * 0.30
|
||||
|
||||
# Vol contribution (inverted: low vol = early, explosive = late)
|
||||
vol_contrib = {"LOW_VOL": 20, "NORMAL_VOL": 40, "HIGH_VOL": 60, "EXPLOSIVE_VOL": 85}
|
||||
vol_val = vol_contrib.get(vol_regime, 50) * 0.20
|
||||
|
||||
return trend_contrib + breadth_contrib + vol_val
|
||||
@@ -1,8 +0,0 @@
|
||||
ccxt>=4.0.0
|
||||
pandas>=2.0.0
|
||||
numpy>=1.21.2
|
||||
pydantic>=2.0.0
|
||||
requests>=2.31.0
|
||||
python-dotenv>=1.0.0
|
||||
scipy>=1.10.0
|
||||
flask>=3.0.0
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
# run_tests.sh — Run the ChanMacro test suite.
|
||||
#
|
||||
# Usage:
|
||||
# ./run_tests.sh # All tests
|
||||
# ./run_tests.sh -v # Verbose
|
||||
# ./run_tests.sh -k regime # Only regime tests
|
||||
# ./run_tests.sh --cov # With coverage (requires pytest-cov)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
python -m pytest tests/ "$@" --tb=short
|
||||
@@ -1,6 +0,0 @@
|
||||
"""Scoring engine — L1 factor computation."""
|
||||
from .base import BaseScorer
|
||||
from .price_structure import PriceStructureScorer
|
||||
from .breadth_scorer import BreadthScorer
|
||||
from .oi_matrix import OIMatrixScorer
|
||||
from .volatility_regime import VolatilityRegimeScorer
|
||||
@@ -1,28 +0,0 @@
|
||||
"""
|
||||
scoring/base.py — Abstract base class for all scoring modules.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
|
||||
from models import FactorScore
|
||||
from config import config
|
||||
|
||||
|
||||
class BaseScorer(ABC):
|
||||
"""Abstract base for all factor scorers."""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
@abstractmethod
|
||||
def compute(self, target_date: Date) -> FactorScore:
|
||||
"""Compute factor score for a given date from database records."""
|
||||
...
|
||||
@@ -1,218 +0,0 @@
|
||||
"""
|
||||
scoring/breadth_scorer.py — Market Breadth Score.
|
||||
|
||||
The first citizen of the system. Diffusion always leads price.
|
||||
|
||||
Multi-tier: Top20 / Top30 / Top50.
|
||||
Quantile-based bucketing: EXTREME / STRONG / NORMAL / WEAK / PANIC.
|
||||
|
||||
4 sub-indicators (equal weight):
|
||||
1. Advance/Decline ratio (30%)
|
||||
2. % above EMA20 (35%)
|
||||
3. New 20d highs (20%)
|
||||
4. BTC Dominance change (15%, inverted)
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .base import BaseScorer
|
||||
from .constants import (
|
||||
BREADTH_W_ADVANCE, BREADTH_W_EMA20, BREADTH_W_NEW_HIGHS, BREADTH_W_BTC_DOM,
|
||||
)
|
||||
from models import FactorScore, BreadthScore, BreadthBucket, MacroDirection
|
||||
from config import config
|
||||
|
||||
|
||||
class BreadthScorer(BaseScorer):
|
||||
"""Scores market breadth with quantile-based bucketing."""
|
||||
|
||||
def compute(self, target_date: Date) -> BreadthScore:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM breadth_daily WHERE date = ?", (str(target_date),)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return BreadthScore(
|
||||
name="Breadth",
|
||||
score=50.0,
|
||||
label="No Data",
|
||||
breadth_bucket=BreadthBucket.NORMAL,
|
||||
)
|
||||
|
||||
row = dict(row)
|
||||
total = row.get("total_tracked", 50) or 50
|
||||
|
||||
# 1. Advance/Decline ratio
|
||||
advance = row.get("advance_top50", 0) or 0
|
||||
decline = row.get("decline_top50", 0) or 0
|
||||
if advance + decline > 0:
|
||||
ad_ratio = advance / (advance + decline)
|
||||
else:
|
||||
ad_ratio = 0.5
|
||||
ad_score = ad_ratio * 100
|
||||
|
||||
# 2. % above EMA20
|
||||
above_ema = row.get("above_ema20_top50", 0) or 0
|
||||
ema_pct = above_ema / total if total > 0 else 0.5
|
||||
ema_score = ema_pct * 100
|
||||
|
||||
# 3. New highs
|
||||
new_highs = row.get("new_highs_20d_top50", 0) or 0
|
||||
highs_pct = new_highs / total if total > 0 else 0
|
||||
highs_score = highs_pct * 100
|
||||
|
||||
# 4. BTC Dominance (inverted: BTC.D up = bearish for alts)
|
||||
btc_dom = row.get("btc_dominance")
|
||||
btc_dom_score = 50.0 # neutral default
|
||||
if btc_dom is not None:
|
||||
# Placeholder — needs historical comparison
|
||||
btc_dom_score = 50.0
|
||||
|
||||
# Weighted aggregate
|
||||
score = (
|
||||
ad_score * BREADTH_W_ADVANCE +
|
||||
ema_score * BREADTH_W_EMA20 +
|
||||
highs_score * BREADTH_W_NEW_HIGHS +
|
||||
btc_dom_score * BREADTH_W_BTC_DOM
|
||||
)
|
||||
|
||||
# Multi-tier breadth
|
||||
b20 = self._compute_tier_breadth(row, 20, total)
|
||||
b30 = self._compute_tier_breadth(row, 30, total)
|
||||
b50 = score # Top50 = full score
|
||||
|
||||
# Quantile bucket
|
||||
bucket = self._assign_bucket(score)
|
||||
|
||||
# Divergence
|
||||
divergence = b20 - b50
|
||||
|
||||
# Direction
|
||||
if score >= 60:
|
||||
direction = MacroDirection.BULLISH
|
||||
elif score <= 40:
|
||||
direction = MacroDirection.BEARISH
|
||||
else:
|
||||
direction = MacroDirection.NEUTRAL
|
||||
|
||||
# Narrative
|
||||
narrative = self._build_narrative(bucket, divergence, ema_pct, ad_ratio)
|
||||
|
||||
return BreadthScore(
|
||||
name="Breadth",
|
||||
score=round(score, 1),
|
||||
label=bucket.value,
|
||||
direction=direction,
|
||||
breadth_top20=round(b20, 1),
|
||||
breadth_top30=round(b30, 1),
|
||||
breadth_top50=round(b50, 1),
|
||||
breadth_bucket=bucket,
|
||||
breadth_divergence=round(divergence, 1),
|
||||
advance_pct_top50=round(ad_ratio * 100, 1),
|
||||
above_ema20_pct_top50=round(ema_pct * 100, 1),
|
||||
new_highs_top50=new_highs,
|
||||
sub_scores={
|
||||
"advance_decline": round(ad_score, 1),
|
||||
"above_ema20": round(ema_score, 1),
|
||||
"new_highs": round(highs_score, 1),
|
||||
"btc_dominance": round(btc_dom_score, 1),
|
||||
},
|
||||
narrative=narrative,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _compute_tier_breadth(self, row: dict, tier: int, total: int) -> float:
|
||||
"""Compute breadth score for a specific tier (Top20 or Top30)."""
|
||||
advance = row.get(f"advance_top{tier}", 0) or 0
|
||||
above_ema = row.get(f"above_ema20_top{tier}", 0) or 0
|
||||
new_highs = row.get(f"new_highs_20d_top{tier}", 0) or 0
|
||||
|
||||
tier_actual = min(tier, total)
|
||||
if tier_actual == 0:
|
||||
return 50.0
|
||||
|
||||
ad_ratio = advance / tier_actual if tier_actual > 0 else 0.5
|
||||
ema_ratio = above_ema / tier_actual if tier_actual > 0 else 0.5
|
||||
highs_ratio = new_highs / tier_actual if tier_actual > 0 else 0
|
||||
|
||||
return (
|
||||
ad_ratio * 100 * BREADTH_W_ADVANCE +
|
||||
ema_ratio * 100 * BREADTH_W_EMA20 +
|
||||
highs_ratio * 100 * BREADTH_W_NEW_HIGHS +
|
||||
50 * BREADTH_W_BTC_DOM # neutral for BTC.D
|
||||
)
|
||||
|
||||
def _assign_bucket(self, score: float) -> BreadthBucket:
|
||||
"""Assign quantile-based bucket. V1 uses fixed thresholds until history accumulated."""
|
||||
# V1: fixed thresholds (will switch to quantile when enough history)
|
||||
if score >= 80:
|
||||
return BreadthBucket.EXTREME
|
||||
elif score >= 60:
|
||||
return BreadthBucket.STRONG
|
||||
elif score >= 40:
|
||||
return BreadthBucket.NORMAL
|
||||
elif score >= 20:
|
||||
return BreadthBucket.WEAK
|
||||
else:
|
||||
return BreadthBucket.PANIC
|
||||
|
||||
@staticmethod
|
||||
def compute_quantile_boundaries(db_path: str) -> dict:
|
||||
"""Compute quantile boundaries from historical breadth data.
|
||||
|
||||
This should be called after accumulating enough history (> 1 year).
|
||||
Returns boundaries for pd.qcut.
|
||||
"""
|
||||
conn = sqlite3.connect(db_path)
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily",
|
||||
conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
if len(df) < 100:
|
||||
return {"boundaries": [0, 20, 40, 60, 80, 100], "is_quantile": False}
|
||||
|
||||
df["ad_ratio"] = df["advance_top50"] / (df["advance_top50"] + df["decline_top50"])
|
||||
df["ema_ratio"] = df["above_ema20_top50"] / 50
|
||||
df["breadth_raw"] = (
|
||||
df["ad_ratio"] * BREADTH_W_ADVANCE * 100 +
|
||||
df["ema_ratio"] * BREADTH_W_EMA20 * 100 +
|
||||
40 * BREADTH_W_NEW_HIGHS +
|
||||
50 * BREADTH_W_BTC_DOM
|
||||
)
|
||||
|
||||
boundaries = list(np.percentile(df["breadth_raw"].dropna(), [10, 30, 70, 90]))
|
||||
return {
|
||||
"boundaries": [0] + boundaries + [100],
|
||||
"is_quantile": True,
|
||||
"n_samples": len(df),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_narrative(bucket: BreadthBucket, divergence: float,
|
||||
ema_pct: float, ad_ratio: float) -> str:
|
||||
parts = []
|
||||
if bucket == BreadthBucket.EXTREME:
|
||||
parts.append(f"全市场极度扩散({ema_pct:.0%}站上EMA20)")
|
||||
elif bucket == BreadthBucket.STRONG:
|
||||
parts.append("市场广度强势")
|
||||
elif bucket == BreadthBucket.NORMAL:
|
||||
parts.append("市场广度中性")
|
||||
elif bucket == BreadthBucket.WEAK:
|
||||
parts.append("市场广度疲弱")
|
||||
else:
|
||||
parts.append("市场广度恐慌")
|
||||
|
||||
if divergence > 10:
|
||||
parts.append("资金集中于大市值(Top20>>Top50)")
|
||||
elif divergence < -10:
|
||||
parts.append("垃圾币狂欢(Top50>>Top20)")
|
||||
|
||||
return ", ".join(parts)
|
||||
@@ -1,98 +0,0 @@
|
||||
"""
|
||||
scoring/constants.py — Scoring thresholds, scale factors, and reference values.
|
||||
|
||||
All magic numbers in one place. Tune these via Phase 0 validation.
|
||||
"""
|
||||
|
||||
# ── Price Structure ──────────────────────────────────────────
|
||||
# ADX thresholds
|
||||
ADX_TREND_THRESHOLD = 25 # ADX > 25 = trending
|
||||
ADX_STRONG_THRESHOLD = 40 # ADX > 40 = strong trend
|
||||
|
||||
# EMA alignment
|
||||
EMA_ALIGNMENT_BULLISH = 1.0 # EMA20 > EMA60 > EMA120
|
||||
EMA_ALIGNMENT_NEUTRAL = 0.5 # mixed
|
||||
EMA_ALIGNMENT_BEARISH = 0.0 # EMA20 < EMA60 < EMA120
|
||||
|
||||
# Volatility compression (BB width relative to 20d average)
|
||||
BB_COMPRESSION_LOW = 0.7 # < 70% of avg = compressing
|
||||
BB_COMPRESSION_HIGH = 1.5 # > 150% of avg = expanding
|
||||
|
||||
# Momentum (ROC annualized)
|
||||
ROC_STRONG_BULLISH = 10.0 # % over period
|
||||
ROC_STRONG_BEARISH = -10.0
|
||||
|
||||
# Consecutive candle threshold
|
||||
CONSECUTIVE_CANDLES_SIGNAL = 4
|
||||
|
||||
# ── Breadth ──────────────────────────────────────────────────
|
||||
# Quantile boundaries for breadth buckets
|
||||
BREADTH_QUANTILES = [0, 0.1, 0.3, 0.7, 0.9, 1.0] # PANIC/WEAK/NORMAL/STRONG/EXTREME
|
||||
|
||||
# Breadth score computation weights
|
||||
BREADTH_W_ADVANCE = 0.30 # advance/decline ratio
|
||||
BREADTH_W_EMA20 = 0.35 # % above EMA20
|
||||
BREADTH_W_NEW_HIGHS = 0.20 # new highs count
|
||||
BREADTH_W_BTC_DOM = 0.15 # BTC dominance change (inverted)
|
||||
|
||||
# ── OI Matrix ────────────────────────────────────────────────
|
||||
OI_PRICE_THRESHOLD = 0.5 # min |price_change%| to classify
|
||||
OI_OI_THRESHOLD = 0.5 # min |OI_change%| to classify
|
||||
|
||||
# Score mapping for OI states
|
||||
OI_STATE_SCORES = {
|
||||
"New Longs": 85,
|
||||
"Short Covering": 60,
|
||||
"New Shorts": 20,
|
||||
"Long Exit": 35,
|
||||
"Neutral": 50,
|
||||
}
|
||||
|
||||
# ── Volatility Regime ────────────────────────────────────────
|
||||
VOL_LOW = 2.0 # ATR/Close % below this = LOW_VOL
|
||||
VOL_HIGH = 5.0 # ATR/Close % below this = HIGH_VOL (above = EXPLOSIVE)
|
||||
HV_RATIO_LOW = 0.7 # HV(20)/HV(60) below this = compressing
|
||||
HV_RATIO_HIGH = 1.5 # HV(20)/HV(60) above this = expanding
|
||||
|
||||
# Score mapping
|
||||
VOL_REGIME_SCORES = {
|
||||
"LOW_VOL": 40, # Low vol → neutral with breakout potential
|
||||
"NORMAL_VOL": 55,
|
||||
"HIGH_VOL": 75,
|
||||
"EXPLOSIVE_VOL": 90,
|
||||
}
|
||||
|
||||
# ── Regime ───────────────────────────────────────────────────
|
||||
REGIME_W_PRICE = 0.35
|
||||
REGIME_W_BREADTH = 0.50
|
||||
REGIME_W_VOL = 0.15
|
||||
|
||||
# PANIC: anti-trend + extreme vol (NO Fear/Liquidation)
|
||||
PANIC_W_ANTI_TREND = 0.60
|
||||
PANIC_W_VOL_EXTREME = 0.40
|
||||
|
||||
# ── Trend (L2) ───────────────────────────────────────────────
|
||||
TREND_W_PRICE = 0.30
|
||||
TREND_W_BREADTH = 0.70
|
||||
|
||||
# ── Maturity ─────────────────────────────────────────────────
|
||||
MATURITY_W_TREND = 0.50
|
||||
MATURITY_W_BREADTH = 0.30
|
||||
MATURITY_W_VOL = 0.20
|
||||
|
||||
# ── Expectancy ───────────────────────────────────────────────
|
||||
HALF_LIFE_DAYS = 180
|
||||
SUFFICIENCY_MIN = 30
|
||||
SUFFICIENCY_LOW = 50
|
||||
SUFFICIENCY_MEDIUM = 100
|
||||
LEVEL_MIN_SAMPLES = 50
|
||||
KNN_MAX_DISTANCE = 0.35
|
||||
KNN_K = 200
|
||||
|
||||
# ── Validation ───────────────────────────────────────────────
|
||||
MIN_AVG_DURATION = 5
|
||||
MAX_FLIP_RATE = 0.15
|
||||
MIN_IC_THRESHOLD = 0.03
|
||||
MIN_ICIR_THRESHOLD = 0.5
|
||||
MIN_IG_THRESHOLD = 0.1 # Information Gain for regime factors
|
||||
MIN_KL_THRESHOLD = 0.5 # KL Divergence for regime separation
|
||||
@@ -1,137 +0,0 @@
|
||||
"""
|
||||
scoring/oi_matrix.py — OI × Price 2×2 state machine.
|
||||
|
||||
Discrete states, NOT a continuous score:
|
||||
NEW_LONGS: Price↑ OI↑ → new money entering, trend continuation
|
||||
SHORT_COVERING: Price↑ OI↓ → shorts covering, rally fragile
|
||||
NEW_SHORTS: Price↓ OI↑ → new shorts entering, trend continuation
|
||||
LONG_EXIT: Price↓ OI↓ → longs stopping out, panic (possible bottom)
|
||||
NEUTRAL: flat → noise, don't force classification
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
import sqlite3
|
||||
|
||||
from .base import BaseScorer
|
||||
from .constants import OI_PRICE_THRESHOLD, OI_OI_THRESHOLD, OI_STATE_SCORES
|
||||
from models import FactorScore, OIMatrixScore, OIState, MacroDirection
|
||||
from config import config
|
||||
|
||||
|
||||
class OIMatrixScorer(BaseScorer):
|
||||
"""Classifies OI × Price state and assigns score."""
|
||||
|
||||
def compute(self, target_date: Date) -> OIMatrixScore:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM derivatives WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
|
||||
(str(target_date),)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
return OIMatrixScore(
|
||||
name="OI Matrix",
|
||||
score=50.0,
|
||||
label="No Data",
|
||||
oi_state=OIState.NEUTRAL,
|
||||
)
|
||||
|
||||
row = dict(row)
|
||||
oi_change = row.get("oi_24h_change_pct") or 0
|
||||
|
||||
# Get price change from OHLCV
|
||||
price_change = self._get_price_change(conn, str(target_date))
|
||||
|
||||
# Classify state
|
||||
oi_state = self._classify(price_change, oi_change)
|
||||
|
||||
# Score from state
|
||||
score = OI_STATE_SCORES.get(oi_state.value, 50)
|
||||
|
||||
# Direction
|
||||
if oi_state == OIState.NEW_LONGS:
|
||||
direction = MacroDirection.BULLISH
|
||||
elif oi_state == OIState.SHORT_COVERING:
|
||||
direction = MacroDirection.BULLISH # bullish but fragile
|
||||
elif oi_state == OIState.NEW_SHORTS:
|
||||
direction = MacroDirection.BEARISH
|
||||
elif oi_state == OIState.LONG_EXIT:
|
||||
direction = MacroDirection.BEARISH # bearish but possible bottom
|
||||
else:
|
||||
direction = MacroDirection.NEUTRAL
|
||||
|
||||
# Narrative
|
||||
narrative = self._build_narrative(oi_state, price_change, oi_change)
|
||||
|
||||
return OIMatrixScore(
|
||||
name="OI Matrix",
|
||||
score=float(score),
|
||||
label=oi_state.value,
|
||||
direction=direction,
|
||||
oi_state=oi_state,
|
||||
price_change_pct=round(price_change, 2),
|
||||
oi_change_pct=round(oi_change, 2),
|
||||
sub_scores={
|
||||
"price_change_pct": round(price_change, 2),
|
||||
"oi_change_pct": round(oi_change, 2),
|
||||
},
|
||||
narrative=narrative,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _get_price_change(self, conn: sqlite3.Connection, date_str: str) -> float:
|
||||
"""Get BTC 24h price change % for a given date."""
|
||||
row = conn.execute(
|
||||
"SELECT close FROM ohlcv_daily WHERE date = ? AND symbol = 'BTC/USDT:USDT'",
|
||||
(date_str,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return 0.0
|
||||
|
||||
# Get previous day close
|
||||
prev = conn.execute(
|
||||
"SELECT close FROM ohlcv_daily WHERE date < ? AND symbol = 'BTC/USDT:USDT' ORDER BY date DESC LIMIT 1",
|
||||
(date_str,)
|
||||
).fetchone()
|
||||
|
||||
if prev is None:
|
||||
return 0.0
|
||||
|
||||
current_close = float(row["close"])
|
||||
prev_close = float(prev["close"])
|
||||
if prev_close == 0:
|
||||
return 0.0
|
||||
|
||||
return (current_close - prev_close) / prev_close * 100
|
||||
|
||||
@staticmethod
|
||||
def _classify(price_change_pct: float, oi_change_pct: float) -> OIState:
|
||||
"""Classify OI × Price into discrete state."""
|
||||
price_up = price_change_pct > OI_PRICE_THRESHOLD
|
||||
price_down = price_change_pct < -OI_PRICE_THRESHOLD
|
||||
oi_up = oi_change_pct > OI_OI_THRESHOLD
|
||||
oi_down = oi_change_pct < -OI_OI_THRESHOLD
|
||||
|
||||
if price_up and oi_up:
|
||||
return OIState.NEW_LONGS
|
||||
elif price_up and oi_down:
|
||||
return OIState.SHORT_COVERING
|
||||
elif price_down and oi_up:
|
||||
return OIState.NEW_SHORTS
|
||||
elif price_down and oi_down:
|
||||
return OIState.LONG_EXIT
|
||||
else:
|
||||
return OIState.NEUTRAL
|
||||
|
||||
@staticmethod
|
||||
def _build_narrative(state: OIState, price_chg: float, oi_chg: float) -> str:
|
||||
mapping = {
|
||||
OIState.NEW_LONGS: f"新多进场: 价格+{price_chg:.1f}%, OI+{oi_chg:.1f}%, 真金白银推动",
|
||||
OIState.SHORT_COVERING: f"空头回补: 价格+{price_chg:.1f}%, OI{oi_chg:.1f}%, 上涨脆弱",
|
||||
OIState.NEW_SHORTS: f"新空进场: 价格{price_chg:.1f}%, OI+{oi_chg:.1f}%, 趋势延续",
|
||||
OIState.LONG_EXIT: f"多头止损: 价格{price_chg:.1f}%, OI{oi_chg:.1f}%, 恐慌(可能见底)",
|
||||
OIState.NEUTRAL: "OI/价格变化不显著, 噪音区",
|
||||
}
|
||||
return mapping.get(state, "Unknown")
|
||||
@@ -1,248 +0,0 @@
|
||||
"""
|
||||
scoring/price_structure.py — Price Structure Score (OHLCV-only).
|
||||
|
||||
Three sub-dimensions:
|
||||
1. Trend Strength (40%): EMA alignment + ADX
|
||||
2. Volatility Compression (30%): ATR + BB width
|
||||
3. Momentum (30%): ROC + consecutive candles
|
||||
|
||||
This module works with zero external dependencies — just OHLCV data.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
import sqlite3
|
||||
import math
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .base import BaseScorer
|
||||
from .constants import (
|
||||
ADX_TREND_THRESHOLD, ADX_STRONG_THRESHOLD,
|
||||
BB_COMPRESSION_LOW, BB_COMPRESSION_HIGH,
|
||||
ROC_STRONG_BULLISH, ROC_STRONG_BEARISH,
|
||||
CONSECUTIVE_CANDLES_SIGNAL,
|
||||
)
|
||||
from models import FactorScore, PriceStructureScore, MacroDirection
|
||||
from config import config
|
||||
|
||||
|
||||
class PriceStructureScorer(BaseScorer):
|
||||
"""Scores market structure from OHLCV data alone."""
|
||||
|
||||
def compute(self, target_date: Date) -> PriceStructureScore:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
df = self._load_ohlcv(conn, str(target_date), lookback=120)
|
||||
if df.empty:
|
||||
return PriceStructureScore(
|
||||
name="Price Structure",
|
||||
score=50.0,
|
||||
label="No Data",
|
||||
)
|
||||
|
||||
trend = self._score_trend_strength(df)
|
||||
vol_comp = self._score_volatility_compression(df)
|
||||
momentum = self._score_momentum(df)
|
||||
|
||||
# Weighted aggregate
|
||||
score = trend * 0.40 + vol_comp * 0.30 + momentum * 0.30
|
||||
|
||||
# Determine direction
|
||||
if trend > 60:
|
||||
direction = MacroDirection.BULLISH
|
||||
elif trend < 40:
|
||||
direction = MacroDirection.BEARISH
|
||||
else:
|
||||
direction = MacroDirection.NEUTRAL
|
||||
|
||||
# Build narrative
|
||||
latest = df.iloc[-1]
|
||||
narrative = self._build_narrative(trend, vol_comp, momentum, latest)
|
||||
|
||||
return PriceStructureScore(
|
||||
name="Price Structure",
|
||||
score=round(score, 1),
|
||||
label=self._label(score),
|
||||
direction=direction,
|
||||
trend_strength=round(trend, 1),
|
||||
volatility_compression=round(vol_comp, 1),
|
||||
momentum=round(momentum, 1),
|
||||
sub_scores={
|
||||
"trend_strength": round(trend, 1),
|
||||
"volatility_compression": round(vol_comp, 1),
|
||||
"momentum": round(momentum, 1),
|
||||
},
|
||||
narrative=narrative,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _load_ohlcv(self, conn: sqlite3.Connection, date_str: str,
|
||||
lookback: int = 120) -> pd.DataFrame:
|
||||
"""Load OHLCV data up to target_date."""
|
||||
df = pd.read_sql_query(
|
||||
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT ?",
|
||||
conn, params=(date_str, lookback)
|
||||
)
|
||||
if df.empty:
|
||||
return df
|
||||
return df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
def _score_trend_strength(self, df: pd.DataFrame) -> float:
|
||||
"""Score trend based on EMA alignment and ADX."""
|
||||
latest = df.iloc[-1]
|
||||
|
||||
# EMA alignment
|
||||
ema20 = latest.get("ema20")
|
||||
ema60 = latest.get("ema60")
|
||||
ema120 = latest.get("ema120")
|
||||
|
||||
ema_score = 50.0
|
||||
if ema20 and ema60 and ema120 and not pd.isna(ema20) and not pd.isna(ema60) and not pd.isna(ema120):
|
||||
alignments = 0
|
||||
if ema20 > ema60: alignments += 1
|
||||
if ema60 > ema120: alignments += 1
|
||||
if ema20 > ema120: alignments += 1
|
||||
|
||||
# Distance from EMAs
|
||||
close = float(latest["close"])
|
||||
ema20_dist = abs(close - ema20) / ema20 * 100 if ema20 else 0
|
||||
|
||||
if alignments == 3:
|
||||
ema_score = 80 + min(ema20_dist, 15) # strong bullish alignment
|
||||
elif alignments == 0:
|
||||
ema_score = 20 - min(ema20_dist, 15) # strong bearish alignment
|
||||
elif alignments == 2:
|
||||
ema_score = 65
|
||||
else:
|
||||
ema_score = 35
|
||||
|
||||
# ADX
|
||||
adx = latest.get("adx_14")
|
||||
adx_score = 50.0
|
||||
if adx and not pd.isna(adx):
|
||||
if adx > ADX_STRONG_THRESHOLD:
|
||||
adx_score = 85
|
||||
elif adx > ADX_TREND_THRESHOLD:
|
||||
adx_score = 65 + (adx - ADX_TREND_THRESHOLD) / (ADX_STRONG_THRESHOLD - ADX_TREND_THRESHOLD) * 20
|
||||
else:
|
||||
adx_score = 50 - (ADX_TREND_THRESHOLD - adx) / ADX_TREND_THRESHOLD * 30
|
||||
|
||||
return ema_score * 0.55 + adx_score * 0.45
|
||||
|
||||
def _score_volatility_compression(self, df: pd.DataFrame) -> float:
|
||||
"""Score volatility compression — expansion = high, compression = low-mid."""
|
||||
latest = df.iloc[-1]
|
||||
|
||||
bb_width = latest.get("bb_width")
|
||||
if not bb_width or pd.isna(bb_width) or len(df) < 20:
|
||||
return 50.0
|
||||
|
||||
# BB width relative to 20d average
|
||||
recent_bb = df["bb_width"].dropna().tail(20)
|
||||
if len(recent_bb) < 10:
|
||||
return 50.0
|
||||
|
||||
bb_avg = recent_bb.mean()
|
||||
bb_ratio = bb_width / bb_avg if bb_avg > 0 else 1.0
|
||||
|
||||
if bb_ratio < BB_COMPRESSION_LOW:
|
||||
# Compression → potential breakout, neutral-bullish
|
||||
return 45 + (BB_COMPRESSION_LOW - bb_ratio) * 30
|
||||
elif bb_ratio > BB_COMPRESSION_HIGH:
|
||||
# Expansion → trending or chaotic
|
||||
return 75 + min((bb_ratio - BB_COMPRESSION_HIGH) * 20, 20)
|
||||
else:
|
||||
# Normal
|
||||
return 55
|
||||
|
||||
def _score_momentum(self, df: pd.DataFrame) -> float:
|
||||
"""Score momentum using ROC and consecutive candles."""
|
||||
if len(df) < 10:
|
||||
return 50.0
|
||||
|
||||
closes = df["close"].astype(float)
|
||||
latest = float(closes.iloc[-1])
|
||||
|
||||
# ROC (5-bar)
|
||||
if len(closes) >= 6:
|
||||
roc5 = (closes.iloc[-1] - closes.iloc[-6]) / closes.iloc[-6] * 100
|
||||
else:
|
||||
roc5 = 0
|
||||
|
||||
# ROC (10-bar)
|
||||
if len(closes) >= 11:
|
||||
roc10 = (closes.iloc[-1] - closes.iloc[-11]) / closes.iloc[-11] * 100
|
||||
else:
|
||||
roc10 = 0
|
||||
|
||||
# ROC (20-bar)
|
||||
if len(closes) >= 21:
|
||||
roc20 = (closes.iloc[-1] - closes.iloc[-21]) / closes.iloc[-21] * 100
|
||||
else:
|
||||
roc20 = 0
|
||||
|
||||
# Score ROC: map to 0-100
|
||||
def roc_to_score(roc, scale=15):
|
||||
return 50 + np.clip(roc / scale * 50, -50, 50)
|
||||
|
||||
roc_score = roc_to_score(roc5, 10) * 0.4 + roc_to_score(roc10, 15) * 0.35 + roc_to_score(roc20, 20) * 0.25
|
||||
|
||||
# Consecutive candle direction
|
||||
consec_score = 50.0
|
||||
consec_up = 0
|
||||
consec_down = 0
|
||||
for i in range(len(closes) - 1, max(0, len(closes) - 10), -1):
|
||||
if closes.iloc[i] > closes.iloc[i - 1]:
|
||||
consec_up += 1
|
||||
consec_down = 0
|
||||
elif closes.iloc[i] < closes.iloc[i - 1]:
|
||||
consec_down += 1
|
||||
consec_up = 0
|
||||
else:
|
||||
break
|
||||
|
||||
if consec_up >= CONSECUTIVE_CANDLES_SIGNAL:
|
||||
consec_score = 70 + min(consec_up * 5, 25)
|
||||
elif consec_down >= CONSECUTIVE_CANDLES_SIGNAL:
|
||||
consec_score = 30 - min(consec_down * 5, 25)
|
||||
|
||||
return roc_score * 0.70 + consec_score * 0.30
|
||||
|
||||
def _build_narrative(self, trend: float, vol: float, momentum: float,
|
||||
latest: pd.Series) -> str:
|
||||
parts = []
|
||||
if trend > 65:
|
||||
parts.append("EMA多头排列+ADX趋势明确")
|
||||
elif trend > 50:
|
||||
parts.append("趋势温和偏多")
|
||||
elif trend < 35:
|
||||
parts.append("EMA空头排列+ADX趋势明确")
|
||||
elif trend < 50:
|
||||
parts.append("趋势温和偏空")
|
||||
else:
|
||||
parts.append("趋势中性")
|
||||
|
||||
if vol > 70:
|
||||
parts.append("波动率扩张")
|
||||
elif vol < 45:
|
||||
parts.append("波动率压缩(突破前兆)")
|
||||
|
||||
if momentum > 65:
|
||||
parts.append("动量强劲")
|
||||
elif momentum < 35:
|
||||
parts.append("动量疲弱")
|
||||
|
||||
return ", ".join(parts) if parts else "中性"
|
||||
|
||||
@staticmethod
|
||||
def _label(score: float) -> str:
|
||||
if score >= 75:
|
||||
return "Strong Bullish Structure"
|
||||
elif score >= 60:
|
||||
return "Bullish Structure"
|
||||
elif score >= 40:
|
||||
return "Neutral Structure"
|
||||
elif score >= 25:
|
||||
return "Bearish Structure"
|
||||
return "Weak Bearish Structure"
|
||||
@@ -1,143 +0,0 @@
|
||||
"""
|
||||
scoring/volatility_regime.py — Volatility Regime Classification.
|
||||
|
||||
4 regimes from OHLCV data:
|
||||
LOW_VOL: ATR/Close < 2% → compression, breakout imminent
|
||||
NORMAL_VOL: ATR/Close 2-5% → normal trading
|
||||
HIGH_VOL: ATR/Close 5-10% → trend acceleration, wider stops
|
||||
EXPLOSIVE_VOL: ATR/Close > 10% → extreme, reduce or wait
|
||||
|
||||
Uses: ATR(14)/Close, HV(20)/HV(60) ratio, BB width ratio.
|
||||
OHLCV-only — never goes offline.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from .base import BaseScorer
|
||||
from .constants import (
|
||||
VOL_LOW, VOL_HIGH, VOL_REGIME_SCORES, HV_RATIO_LOW, HV_RATIO_HIGH,
|
||||
)
|
||||
from models import FactorScore, VolatilityRegimeScore, VolRegime, MacroDirection
|
||||
from config import config
|
||||
|
||||
|
||||
class VolatilityRegimeScorer(BaseScorer):
|
||||
"""Classifies volatility regime from OHLCV data."""
|
||||
|
||||
def compute(self, target_date: Date) -> VolatilityRegimeScore:
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
df = pd.read_sql_query(
|
||||
"SELECT * FROM ohlcv_daily WHERE date <= ? ORDER BY date DESC LIMIT 120",
|
||||
conn, params=(str(target_date),)
|
||||
)
|
||||
if df.empty:
|
||||
return VolatilityRegimeScore(
|
||||
name="Volatility Regime",
|
||||
score=50.0,
|
||||
label="No Data",
|
||||
)
|
||||
|
||||
df = df.sort_values("date").reset_index(drop=True)
|
||||
|
||||
# 1. ATR/Close %
|
||||
latest = df.iloc[-1]
|
||||
atr = latest.get("atr_14")
|
||||
close = float(latest["close"])
|
||||
atr_pct = (atr / close * 100) if atr and not pd.isna(atr) and close > 0 else 3.0
|
||||
|
||||
# 2. HV(20) / HV(60) ratio
|
||||
hv_ratio = self._compute_hv_ratio(df)
|
||||
|
||||
# 3. BB width ratio
|
||||
bb_ratio = self._compute_bb_ratio(df)
|
||||
|
||||
# Classify regime
|
||||
regime = self._classify(atr_pct, hv_ratio, bb_ratio)
|
||||
|
||||
# Score
|
||||
score = VOL_REGIME_SCORES.get(regime.value, 50)
|
||||
|
||||
# Narrative
|
||||
narrative = self._build_narrative(regime, atr_pct, hv_ratio, bb_ratio)
|
||||
|
||||
return VolatilityRegimeScore(
|
||||
name="Volatility Regime",
|
||||
score=float(score),
|
||||
label=regime.value,
|
||||
direction=MacroDirection.NEUTRAL,
|
||||
vol_regime=regime,
|
||||
atr_pct=round(atr_pct, 2),
|
||||
hv_ratio=round(hv_ratio, 2),
|
||||
bb_width_ratio=round(bb_ratio, 2),
|
||||
sub_scores={
|
||||
"atr_pct": round(atr_pct, 2),
|
||||
"hv_ratio": round(hv_ratio, 2),
|
||||
"bb_width_ratio": round(bb_ratio, 2),
|
||||
},
|
||||
narrative=narrative,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _compute_hv_ratio(self, df: pd.DataFrame) -> float:
|
||||
"""Compute HV(20) / HV(60) ratio."""
|
||||
closes = df["close"].astype(float)
|
||||
returns = closes.pct_change().dropna()
|
||||
|
||||
if len(returns) < 60:
|
||||
return 1.0
|
||||
|
||||
hv20 = returns.tail(20).std() * np.sqrt(365) * 100
|
||||
hv60 = returns.tail(60).std() * np.sqrt(365) * 100
|
||||
|
||||
if hv60 == 0:
|
||||
return 1.0
|
||||
|
||||
return hv20 / hv60
|
||||
|
||||
def _compute_bb_ratio(self, df: pd.DataFrame) -> float:
|
||||
"""Compute current BB width / 20d average BB width."""
|
||||
bb_widths = df["bb_width"].dropna().tail(40)
|
||||
if len(bb_widths) < 20:
|
||||
return 1.0
|
||||
|
||||
current = bb_widths.iloc[-1]
|
||||
avg = bb_widths.tail(20).mean()
|
||||
if avg == 0:
|
||||
return 1.0
|
||||
|
||||
return current / avg
|
||||
|
||||
@staticmethod
|
||||
def _classify(atr_pct: float, hv_ratio: float, bb_ratio: float) -> VolRegime:
|
||||
"""Classify volatility regime from multiple indicators."""
|
||||
# Primary: ATR/Close %
|
||||
if atr_pct > 10.0:
|
||||
return VolRegime.EXPLOSIVE_VOL
|
||||
elif atr_pct > VOL_HIGH:
|
||||
return VolRegime.HIGH_VOL
|
||||
elif atr_pct < VOL_LOW:
|
||||
return VolRegime.LOW_VOL
|
||||
|
||||
# Secondary: HV ratio and BB ratio for edge cases
|
||||
if hv_ratio > HV_RATIO_HIGH and bb_ratio > 1.3:
|
||||
return VolRegime.HIGH_VOL
|
||||
elif hv_ratio < HV_RATIO_LOW and bb_ratio < 0.8:
|
||||
return VolRegime.LOW_VOL
|
||||
|
||||
return VolRegime.NORMAL_VOL
|
||||
|
||||
@staticmethod
|
||||
def _build_narrative(regime: VolRegime, atr_pct: float,
|
||||
hv_ratio: float, bb_ratio: float) -> str:
|
||||
mapping = {
|
||||
VolRegime.LOW_VOL: f"低波动(ATR={atr_pct:.1f}%), 布林带收窄, 突破前兆",
|
||||
VolRegime.NORMAL_VOL: f"正常波动(ATR={atr_pct:.1f}%), 正常交易环境",
|
||||
VolRegime.HIGH_VOL: f"高波动(ATR={atr_pct:.1f}%), 趋势加速, 放宽止损",
|
||||
VolRegime.EXPLOSIVE_VOL: f"极端波动(ATR={atr_pct:.1f}%), 减仓或等待",
|
||||
}
|
||||
return mapping.get(regime, "Unknown")
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
tests/conftest.py — Shared fixtures for ChanMacro tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure package root on path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path):
|
||||
"""Create a temporary SQLite database with full mock data."""
|
||||
db = str(tmp_path / "test_macro.db")
|
||||
from database import init_db
|
||||
conn = init_db(db)
|
||||
|
||||
np.random.seed(42)
|
||||
base = date(2025, 9, 1)
|
||||
n_days = 300
|
||||
|
||||
# Generate realistic price series with 3 regime periods
|
||||
prices = [90000]
|
||||
regimes = []
|
||||
for i in range(n_days):
|
||||
if i < 100:
|
||||
ret = np.random.normal(0.003, 0.015)
|
||||
regime = "TREND"
|
||||
elif i < 200:
|
||||
ret = np.random.normal(0.000, 0.012)
|
||||
regime = "RANGE"
|
||||
else:
|
||||
ret = np.random.normal(-0.003, 0.025)
|
||||
regime = "PANIC"
|
||||
prices.append(prices[-1] * (1 + ret))
|
||||
regimes.append(regime)
|
||||
|
||||
for i in range(n_days):
|
||||
d = base + timedelta(days=i)
|
||||
c = prices[i]
|
||||
r = regimes[i]
|
||||
|
||||
# OHLCV
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO ohlcv_daily
|
||||
(date,symbol,open,high,low,close,volume,ema20,ema60,ema120,atr_14,bb_width,adx_14)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
|
||||
c * 0.99, c * 1.03, c * 0.97, c, 1000,
|
||||
c * (0.98 if r == "TREND" else 1.02 if r == "PANIC" else 1.0),
|
||||
c * (0.95 if r == "TREND" else 1.05 if r == "PANIC" else 1.0),
|
||||
c * (0.90 if r == "TREND" else 1.10 if r == "PANIC" else 1.0),
|
||||
c * (0.02 if r == "PANIC" else 0.015),
|
||||
4.5, 28.0 if r == "TREND" else 18.0,
|
||||
))
|
||||
|
||||
# Breadth
|
||||
adv = 42 if r == "TREND" else 25 if r == "RANGE" else 8
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO breadth_daily
|
||||
(date,total_tracked,advance_top50,decline_top50,above_ema20_top50,
|
||||
new_highs_20d_top50,advance_top30,advance_top20,
|
||||
above_ema20_top30,above_ema20_top20,new_highs_20d_top30,new_highs_20d_top20)
|
||||
VALUES (?,50,?,?,?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d.strftime("%Y-%m-%d"), adv, 50 - adv, adv, min(adv, 15),
|
||||
int(adv * 0.7), int(adv * 0.5), int(adv * 0.7), int(adv * 0.5),
|
||||
min(int(adv * 0.7), 12), min(int(adv * 0.5), 8),
|
||||
))
|
||||
|
||||
# Derivatives
|
||||
oi_chg = 3.5 if r == "TREND" else 0.5 if r == "RANGE" else -2.0
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO derivatives
|
||||
(date,symbol,funding_rate,open_interest,oi_24h_change_pct,
|
||||
long_liquidations,short_liquidations,basis_annualised_pct)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
""", (
|
||||
d.strftime("%Y-%m-%d"), "BTC/USDT:USDT",
|
||||
0.0001 + np.random.normal(0, 0.0002),
|
||||
35e9, oi_chg + np.random.normal(0, 1.0),
|
||||
50e6 * np.random.random(), 30e6 * np.random.random(),
|
||||
8.5 if r == "TREND" else 3.0,
|
||||
))
|
||||
|
||||
# Regime history
|
||||
conn.execute("""
|
||||
INSERT OR REPLACE INTO regime_history
|
||||
(date,regime,confidence,regime_version,maturity_score,all_scores_json,confirmation_days)
|
||||
VALUES (?,?,?,?,?,?,?)
|
||||
""", (d.strftime("%Y-%m-%d"), r, 0.75, "v1_price_breadth_vol", 50, "{}", 1))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Override config to use test DB
|
||||
from config import config
|
||||
old_db = config.db_path
|
||||
config.db_path = db
|
||||
yield db
|
||||
config.db_path = old_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_state(db_path):
|
||||
"""Build a MarketStateVector for a known test date."""
|
||||
from models import (
|
||||
MarketStateVector, MarketRegime, BreadthBucket,
|
||||
OIState, VolRegime,
|
||||
)
|
||||
state = MarketStateVector(
|
||||
date=date(2026, 3, 15),
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
regime_maturity_score=55.0,
|
||||
breadth_top20=82.0,
|
||||
breadth_top30=78.0,
|
||||
breadth_top50=74.0,
|
||||
breadth_bucket=BreadthBucket.STRONG,
|
||||
breadth_divergence=8.0,
|
||||
oi_state=OIState.NEW_LONGS,
|
||||
volatility_regime=VolRegime.NORMAL_VOL,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
return state
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Test SignalTracker, TimeDecay, and BayesianExpectancyEngine."""
|
||||
import pytest
|
||||
from datetime import date, timedelta
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TestTimeDecay:
|
||||
def test_recent_weight_near_one(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
w = d.weight(date(2026, 6, 20), date(2026, 6, 24))
|
||||
assert 0.95 < w < 1.0
|
||||
|
||||
def test_old_weight_decays(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
w = d.weight(date(2025, 6, 24), date(2026, 6, 24))
|
||||
assert 0.2 < w < 0.3 # ~365 days at half_life=180
|
||||
|
||||
def test_effective_samples(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
dates = [date(2026, 6, 24)] * 10
|
||||
weights = d.weights(dates, date(2026, 6, 24))
|
||||
eff = d.effective_samples(weights)
|
||||
assert eff == pytest.approx(10.0, rel=0.01)
|
||||
|
||||
def test_weighted_win_rate(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
d = TimeDecay(180)
|
||||
wins = np.array([1, 0, 1, 0])
|
||||
weights = np.array([1.0, 1.0, 1.0, 1.0])
|
||||
wr = d.weighted_win_rate(wins, weights)
|
||||
assert wr == 0.5
|
||||
|
||||
def test_weight_at_age(self):
|
||||
from expectancy.decay import TimeDecay
|
||||
w = TimeDecay.weight_at_age(180, 180)
|
||||
assert w == pytest.approx(0.5, rel=0.01)
|
||||
|
||||
|
||||
class TestSignalTracker:
|
||||
def test_record_signal(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
rid = tracker.record(
|
||||
date(2026, 3, 15), "B3", 98000.0, sample_state,
|
||||
signal_grade="A", signal_strength=75.0,
|
||||
)
|
||||
assert rid is not None
|
||||
assert rid > 0
|
||||
|
||||
def test_get_samples(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||
tracker.record(date(2026, 3, 16), "B2", 98500.0, sample_state)
|
||||
|
||||
samples = tracker.get_samples(signal_type="B3")
|
||||
assert len(samples) == 1
|
||||
assert samples[0]["signal_type"] == "B3"
|
||||
|
||||
def test_count_samples(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||
tracker.record(date(2026, 3, 16), "B3", 98500.0, sample_state)
|
||||
|
||||
counts = tracker.count_samples()
|
||||
assert "B3/TREND" in counts
|
||||
assert counts["B3/TREND"] == 2
|
||||
|
||||
def test_filter_by_regime(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
tracker.record(date(2026, 3, 15), "B3", 98000.0, sample_state)
|
||||
|
||||
samples = tracker.get_samples(signal_type="B3", regime="TREND")
|
||||
assert len(samples) == 1
|
||||
|
||||
samples = tracker.get_samples(signal_type="B3", regime="PANIC")
|
||||
assert len(samples) == 0
|
||||
|
||||
def test_backfill_signals(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
tracker = SignalTracker()
|
||||
signals = [
|
||||
{"date": date(2026, 3, 15), "signal_type": "B3", "entry_price": 98000},
|
||||
{"date": date(2026, 3, 20), "signal_type": "B2", "entry_price": 99000},
|
||||
]
|
||||
count = tracker.backfill_signals(signals)
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestBayesianExpectancyEngine:
|
||||
def test_estimate_returns_report(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
# Record some signals first
|
||||
tracker = SignalTracker()
|
||||
for i in range(10):
|
||||
tracker.record(
|
||||
date(2026, 3, 15) + timedelta(days=i),
|
||||
"B3", 98000.0, sample_state,
|
||||
)
|
||||
|
||||
engine = BayesianExpectancyEngine(level_min_samples=3)
|
||||
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||
assert report.signal_type == "B3"
|
||||
assert len(report.layers) > 0
|
||||
assert report.source in ("bayesian", "insufficient")
|
||||
|
||||
def test_insufficient_with_no_samples(self, db_path, sample_state):
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
engine = BayesianExpectancyEngine(level_min_samples=10)
|
||||
report = engine.estimate(sample_state, "B1", date(2026, 3, 25))
|
||||
assert report.sufficiency.value in ("INSUFFICIENT", "LOW", "MEDIUM", "HIGH")
|
||||
|
||||
def test_empirical_bayes_shrinks_small_samples(self, db_path, sample_state):
|
||||
"""With N=3, raw=100%, posterior should be pulled toward prior."""
|
||||
from expectancy.tracker import SignalTracker
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
tracker = SignalTracker()
|
||||
for i in range(3):
|
||||
tracker.record(
|
||||
date(2026, 3, 15) + timedelta(days=i),
|
||||
"B3", 98000.0, sample_state,
|
||||
)
|
||||
|
||||
engine = BayesianExpectancyEngine(level_min_samples=1)
|
||||
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||
|
||||
# With small N, posterior should differ from raw
|
||||
base_layer = report.layers[0]
|
||||
if base_layer.raw_winrate and base_layer.samples < 50:
|
||||
# Posterior should be pulled toward prior (50% or global rate)
|
||||
if base_layer.raw_winrate > 0.8:
|
||||
assert base_layer.posterior_winrate < base_layer.raw_winrate
|
||||
|
||||
def test_leveled_fallback_stops_at_min_samples(self, db_path, sample_state):
|
||||
from expectancy.tracker import SignalTracker
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
tracker = SignalTracker()
|
||||
for i in range(20):
|
||||
tracker.record(date(2026, 3, 15) + timedelta(days=i), "B3", 98000.0, sample_state)
|
||||
|
||||
engine = BayesianExpectancyEngine(level_min_samples=15)
|
||||
report = engine.estimate(sample_state, "B3", date(2026, 3, 25))
|
||||
# Should have stopped at a level with >= 15 effective samples
|
||||
assert report.final_estimate >= 0
|
||||
|
||||
|
||||
class TestSufficiencyGuard:
|
||||
def test_insufficient(self):
|
||||
from expectancy.engine import SufficiencyGuard
|
||||
from models import SufficiencyLevel
|
||||
g = SufficiencyGuard()
|
||||
assert g.evaluate(10) == SufficiencyLevel.INSUFFICIENT
|
||||
|
||||
def test_low(self):
|
||||
from expectancy.engine import SufficiencyGuard
|
||||
from models import SufficiencyLevel
|
||||
g = SufficiencyGuard()
|
||||
assert g.evaluate(40) == SufficiencyLevel.LOW
|
||||
|
||||
def test_high(self):
|
||||
from expectancy.engine import SufficiencyGuard
|
||||
from models import SufficiencyLevel
|
||||
g = SufficiencyGuard()
|
||||
assert g.evaluate(200) == SufficiencyLevel.HIGH
|
||||
@@ -1,130 +0,0 @@
|
||||
"""Test all Pydantic models and enums."""
|
||||
import pytest
|
||||
from datetime import date
|
||||
from models import (
|
||||
MarketRegime, OIState, BreadthBucket, VolRegime,
|
||||
MarketStateVector, FactorScore, RegimeResult,
|
||||
SignalFeatureRecord, ExpectancyReport, DailyOutput,
|
||||
FactorContribution, SufficiencyLevel, SignalGrade,
|
||||
)
|
||||
|
||||
|
||||
class TestEnums:
|
||||
def test_regime_values(self):
|
||||
assert MarketRegime.TREND.value == "TREND"
|
||||
assert MarketRegime.RANGE.value == "RANGE"
|
||||
assert MarketRegime.PANIC.value == "PANIC"
|
||||
|
||||
def test_oi_state_has_neutral(self):
|
||||
assert OIState.NEUTRAL.value == "Neutral"
|
||||
assert len(OIState) == 5
|
||||
|
||||
def test_breadth_bucket_values(self):
|
||||
assert BreadthBucket.EXTREME.value == "EXTREME"
|
||||
assert len(BreadthBucket) == 5
|
||||
|
||||
def test_vol_regime_values(self):
|
||||
assert VolRegime.LOW_VOL.value == "LOW_VOL"
|
||||
assert VolRegime.EXPLOSIVE_VOL.value == "EXPLOSIVE_VOL"
|
||||
|
||||
|
||||
class TestMarketStateVector:
|
||||
def test_minimal_construction(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
)
|
||||
assert sv.date == date(2026, 6, 24)
|
||||
assert sv.regime == MarketRegime.TREND
|
||||
assert sv.breadth_top50 == 50.0 # default
|
||||
|
||||
def test_date_string_parsing(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-01-15",
|
||||
regime=MarketRegime.RANGE,
|
||||
regime_confidence=0.55,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
)
|
||||
assert sv.date == date(2026, 1, 15)
|
||||
|
||||
def test_compute_hash(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
breadth_bucket=BreadthBucket.EXTREME,
|
||||
oi_state=OIState.NEW_LONGS,
|
||||
volatility_regime=VolRegime.NORMAL_VOL,
|
||||
)
|
||||
h = sv.compute_hash()
|
||||
assert len(h) == 12
|
||||
# Same state = same hash
|
||||
sv2 = MarketStateVector(
|
||||
date="2026-06-25",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.80,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
breadth_bucket=BreadthBucket.EXTREME,
|
||||
oi_state=OIState.NEW_LONGS,
|
||||
volatility_regime=VolRegime.NORMAL_VOL,
|
||||
)
|
||||
assert sv2.compute_hash() == h
|
||||
|
||||
def test_state_embedding(self):
|
||||
sv = MarketStateVector(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
regime_confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
breadth_top20=80.0,
|
||||
breadth_top30=75.0,
|
||||
breadth_top50=70.0,
|
||||
regime_maturity_score=60.0,
|
||||
)
|
||||
emb = sv.state_embedding()
|
||||
assert len(emb) == 5
|
||||
assert emb[0] == 80.0
|
||||
assert emb[3] == 60.0
|
||||
|
||||
|
||||
class TestRegimeResult:
|
||||
def test_construction(self):
|
||||
r = RegimeResult(
|
||||
date="2026-06-24",
|
||||
regime=MarketRegime.TREND,
|
||||
confidence=0.82,
|
||||
regime_version="v1_price_breadth_vol",
|
||||
maturity_score=55.0,
|
||||
all_scores={"TREND": 82.0, "RANGE": 45.0, "PANIC": 20.0},
|
||||
confirmation_days=5,
|
||||
)
|
||||
assert r.regime == MarketRegime.TREND
|
||||
assert r.confirmation_days == 5
|
||||
|
||||
|
||||
class TestExpectancyReport:
|
||||
def test_insufficient(self):
|
||||
r = ExpectancyReport(
|
||||
signal_type="B3",
|
||||
date="2026-06-24",
|
||||
final_estimate=0.0,
|
||||
sufficiency=SufficiencyLevel.INSUFFICIENT,
|
||||
source="insufficient",
|
||||
)
|
||||
assert r.final_estimate == 0.0
|
||||
assert r.sufficiency == SufficiencyLevel.INSUFFICIENT
|
||||
|
||||
|
||||
class TestFactorContribution:
|
||||
def test_construction(self):
|
||||
fc = FactorContribution(
|
||||
factor="ETF Flow",
|
||||
raw_score=85.0,
|
||||
weight=0.1925,
|
||||
impact=6.7,
|
||||
direction="bullish",
|
||||
)
|
||||
assert fc.impact > 0
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Test regime detector and validation."""
|
||||
import pytest
|
||||
from datetime import date
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
|
||||
class TestRegimeDetector:
|
||||
def test_detects_trend(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
assert r.regime == MarketRegime.TREND
|
||||
assert r.confidence > 0.5
|
||||
|
||||
def test_detects_range(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
r = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
|
||||
assert r.regime in (MarketRegime.RANGE, MarketRegime.TREND)
|
||||
|
||||
def test_detects_panic(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 24))
|
||||
assert r.regime == MarketRegime.PANIC
|
||||
|
||||
def test_2day_confirmation(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
# Day 1: RANGE
|
||||
r1 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 24))
|
||||
assert r1.regime == MarketRegime.RANGE # first run, no confirmation needed
|
||||
# Day 2: still RANGE
|
||||
r2 = d.detect(50.0, 50.0, "LOW_VOL", date(2026, 6, 25))
|
||||
assert r2.regime == MarketRegime.RANGE
|
||||
assert r2.confirmation_days == 2
|
||||
|
||||
def test_transition_needs_confirmation(self):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
# Establish TREND
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
|
||||
# Day 3: weak scores → raw best = RANGE, but TREND should persist
|
||||
r3 = d.detect(35.0, 40.0, "NORMAL_VOL", date(2026, 6, 26))
|
||||
# First day of pending transition — should still be TREND
|
||||
assert r3.regime == MarketRegime.TREND
|
||||
assert d.pending_regime is not None
|
||||
|
||||
def test_version_is_stored(self):
|
||||
from regime_detector import RegimeDetector
|
||||
d = RegimeDetector(regime_version="v1_price_breadth_vol")
|
||||
r = d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
assert r.regime_version == "v1_price_breadth_vol"
|
||||
|
||||
def test_load_state(self, db_path):
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
d.load_state(db_path)
|
||||
# DB has TREND for first 100 days, so most recent should load
|
||||
assert d.current_regime is not None
|
||||
|
||||
def test_confidence_for_confirmed_regime(self):
|
||||
"""Confidence should be for the confirmed regime, not raw best."""
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketRegime
|
||||
d = RegimeDetector()
|
||||
# Establish TREND
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 24))
|
||||
d.detect(75.0, 80.0, "NORMAL_VOL", date(2026, 6, 25))
|
||||
# Now feed weak scores → raw best would be PANIC or RANGE
|
||||
r = d.detect(15.0, 10.0, "EXPLOSIVE_VOL", date(2026, 6, 26))
|
||||
# Should still report TREND (need 2 confirmations to switch)
|
||||
assert r.regime == MarketRegime.TREND
|
||||
|
||||
|
||||
class TestTransitionValidator:
|
||||
def test_stable_regime_passes(self):
|
||||
from validation.transition_validator import TransitionValidator
|
||||
# Create stable regime sequence: long periods
|
||||
seq = pd.Series(
|
||||
["TREND"] * 50 + ["RANGE"] * 50 + ["PANIC"] * 40,
|
||||
index=pd.date_range("2026-01-01", periods=140),
|
||||
)
|
||||
tv = TransitionValidator()
|
||||
report = tv.validate(seq)
|
||||
assert report.is_stable
|
||||
assert report.avg_duration > 20
|
||||
assert report.flip_rate < 0.05
|
||||
|
||||
def test_unstable_regime_fails(self):
|
||||
from validation.transition_validator import TransitionValidator
|
||||
# Create unstable sequence: flips every 2 days
|
||||
seq = pd.Series(
|
||||
["TREND", "TREND", "RANGE", "RANGE", "TREND", "TREND",
|
||||
"PANIC", "PANIC", "RANGE", "RANGE"] * 5,
|
||||
index=pd.date_range("2026-01-01", periods=50),
|
||||
)
|
||||
tv = TransitionValidator()
|
||||
report = tv.validate(seq)
|
||||
assert not report.is_stable
|
||||
assert report.flip_rate > 0.15
|
||||
@@ -1,121 +0,0 @@
|
||||
"""Test all 4 core scorers."""
|
||||
import pytest
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TestPriceStructureScorer:
|
||||
def test_computes_score(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2026, 3, 15))
|
||||
assert result.name == "Price Structure"
|
||||
assert 0 <= result.score <= 100
|
||||
assert result.trend_strength >= 0
|
||||
assert result.volatility_compression >= 0
|
||||
assert result.momentum >= 0
|
||||
assert result.label
|
||||
|
||||
def test_bullish_in_trend(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
||||
assert result.score > 50 # Should be bullish in uptrend
|
||||
|
||||
def test_bearish_in_panic(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2026, 5, 15)) # PANIC period
|
||||
# In panic period, EMA alignment should be bearish
|
||||
assert result.trend_strength < 60
|
||||
|
||||
def test_no_data_handling(self, db_path):
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
scorer = PriceStructureScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
assert result.label == "No Data"
|
||||
|
||||
|
||||
class TestBreadthScorer:
|
||||
def test_computes_score(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2026, 3, 15))
|
||||
assert result.name == "Breadth"
|
||||
assert 0 <= result.score <= 100
|
||||
assert result.breadth_bucket
|
||||
assert result.breadth_top20 >= 0
|
||||
assert result.breadth_top50 >= 0
|
||||
|
||||
def test_tier_values(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND period
|
||||
# Top20 should generally be higher than Top50 (large caps lead)
|
||||
assert result.breadth_top20 >= 0
|
||||
assert result.breadth_top50 >= 0
|
||||
|
||||
def test_bucket_assignment(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer, BreadthBucket
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND: adv=42/50
|
||||
assert result.breadth_bucket in (
|
||||
BreadthBucket.EXTREME, BreadthBucket.STRONG, BreadthBucket.NORMAL
|
||||
)
|
||||
|
||||
def test_no_data(self, db_path):
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
scorer = BreadthScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
|
||||
|
||||
class TestOIMatrixScorer:
|
||||
def test_computes_state(self, db_path):
|
||||
from scoring.oi_matrix import OIMatrixScorer, OIState
|
||||
scorer = OIMatrixScorer()
|
||||
result = scorer.compute(date(2025, 11, 15)) # TREND period, oi_chg=+3.5
|
||||
assert result.oi_state in OIState
|
||||
assert 0 <= result.score <= 100
|
||||
|
||||
def test_new_longs_in_trend(self, db_path):
|
||||
from scoring.oi_matrix import OIMatrixScorer, OIState
|
||||
scorer = OIMatrixScorer()
|
||||
# Test multiple dates in TREND period — at least one should be NEW_LONGS or NEUTRAL
|
||||
found_bullish = False
|
||||
for d in ["2025-11-15", "2025-11-20", "2025-12-01", "2025-12-15"]:
|
||||
result = scorer.compute(date.fromisoformat(d))
|
||||
if result.oi_state in (OIState.NEW_LONGS, OIState.SHORT_COVERING, OIState.NEUTRAL):
|
||||
found_bullish = True
|
||||
break
|
||||
assert found_bullish, "No bullish OI state found in TREND period"
|
||||
|
||||
def test_no_data(self, db_path):
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
scorer = OIMatrixScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
assert result.label == "No Data"
|
||||
|
||||
|
||||
class TestVolatilityRegimeScorer:
|
||||
def test_computes_regime(self, db_path):
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
||||
scorer = VolatilityRegimeScorer()
|
||||
result = scorer.compute(date(2026, 3, 15))
|
||||
assert result.vol_regime in VolRegime
|
||||
assert 0 <= result.score <= 100
|
||||
|
||||
def test_higher_vol_in_panic(self, db_path):
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer, VolRegime
|
||||
scorer = VolatilityRegimeScorer()
|
||||
trend_result = scorer.compute(date(2025, 11, 15))
|
||||
panic_result = scorer.compute(date(2026, 5, 15))
|
||||
# PANIC period has higher ATR → higher vol regime or score
|
||||
assert panic_result.atr_pct >= trend_result.atr_pct * 0.5 # at least comparable
|
||||
|
||||
def test_no_data(self, db_path):
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
scorer = VolatilityRegimeScorer()
|
||||
result = scorer.compute(date(2020, 1, 1))
|
||||
assert result.score == 50.0
|
||||
@@ -1,81 +0,0 @@
|
||||
"""
|
||||
trend_detector.py — Trend strength and maturity helpers.
|
||||
|
||||
Utility functions for computing trend alignment, acceleration, persistence.
|
||||
Used by regime_detector and price_structure scorer.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def ema_alignment_score(close: float, ema20: float, ema60: float, ema120: float) -> float:
|
||||
"""Score EMA alignment: 0=bearish, 50=neutral, 100=bullish."""
|
||||
if any(pd.isna(x) for x in [ema20, ema60, ema120]):
|
||||
return 50.0
|
||||
|
||||
alignments = 0
|
||||
if ema20 > ema60:
|
||||
alignments += 1
|
||||
if ema60 > ema120:
|
||||
alignments += 1
|
||||
if ema20 > ema120:
|
||||
alignments += 1
|
||||
|
||||
if alignments == 3:
|
||||
return 85.0
|
||||
elif alignments == 2:
|
||||
return 65.0
|
||||
elif alignments == 1:
|
||||
return 35.0
|
||||
else:
|
||||
return 15.0
|
||||
|
||||
|
||||
def adx_trend_score(adx: float) -> float:
|
||||
"""Convert ADX value to trend score: 0-100."""
|
||||
if pd.isna(adx):
|
||||
return 50.0
|
||||
if adx > 40:
|
||||
return 90.0
|
||||
elif adx > 25:
|
||||
return 60.0 + (adx - 25) / 15 * 30
|
||||
elif adx > 15:
|
||||
return 40.0 + (adx - 15) / 10 * 20
|
||||
else:
|
||||
return max(10.0, adx / 15 * 40)
|
||||
|
||||
|
||||
def breadth_persistence(breadth_scores: list[float], window: int = 5) -> float:
|
||||
"""How consistently has breadth stayed at its current level? 0-100."""
|
||||
if len(breadth_scores) < window:
|
||||
return 50.0
|
||||
recent = breadth_scores[-window:]
|
||||
mean_val = np.mean(recent)
|
||||
std_val = np.std(recent) if len(recent) > 1 else 0
|
||||
# Low std = high persistence
|
||||
persistence = 100 - min(std_val * 5, 100)
|
||||
# Bias: higher breadth = higher persistence score
|
||||
return persistence * 0.5 + mean_val * 0.5
|
||||
|
||||
|
||||
def trend_strength_composite(ema_score: float, adx_score: float,
|
||||
breadth_score: float) -> float:
|
||||
"""Composite trend strength 0-100."""
|
||||
return ema_score * 0.25 + adx_score * 0.25 + breadth_score * 0.50
|
||||
|
||||
|
||||
def compute_maturity(trend_strength: float, breadth_persistence: float,
|
||||
vol_expansion: float) -> float:
|
||||
"""
|
||||
Compute regime maturity score 0-100.
|
||||
|
||||
EMERGING (0-30): trend accelerating, breadth expanding
|
||||
CONFIRMED (30-70): trend stable, breadth stable
|
||||
EXHAUSTING (70-100): trend decelerating, breadth contracting, vol abnormal
|
||||
"""
|
||||
return (
|
||||
trend_strength * 0.50 +
|
||||
breadth_persistence * 0.30 +
|
||||
(100 - vol_expansion) * 0.20 # inverted: low vol = early stage
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Validation Framework — Phase 0: verify every factor before trusting it."""
|
||||
from .factor_validator import FactorValidator
|
||||
from .regime_validator import RegimeValidator
|
||||
from .transition_validator import TransitionValidator
|
||||
from .reporter import ValidationReporter
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
validation/factor_validator.py — Validates a factor's predictive power.
|
||||
|
||||
Tests: IC, ICIR, Hit Ratio, Quantile Spread, Lead-Lag analysis.
|
||||
Answers: "Does this factor predict future returns?"
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import config
|
||||
from .metrics import (
|
||||
information_coefficient, icir, hit_ratio,
|
||||
quantile_spread, lead_lag_ic,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FactorReport:
|
||||
"""Structured report for a single factor's validation results."""
|
||||
|
||||
def __init__(self, factor_name: str):
|
||||
self.factor_name = factor_name
|
||||
self.ic_mean: float = 0.0
|
||||
self.ic_std: float = 0.0
|
||||
self.icir: float = 0.0
|
||||
self.hit_ratio: float = 0.0
|
||||
self.quantile_spread: float = 0.0
|
||||
self.is_leading: bool = False
|
||||
self.lead_days: int = 0
|
||||
self.lead_ic: float = 0.0
|
||||
self.n_observations: int = 0
|
||||
self.conclusion: str = ""
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Factor: {self.factor_name}",
|
||||
f" N={self.n_observations}",
|
||||
f" IC mean={self.ic_mean:.4f} std={self.ic_std:.4f} ICIR={self.icir:.2f}",
|
||||
f" Hit Ratio={self.hit_ratio:.1%} Top-Bot Spread={self.quantile_spread:.4f}",
|
||||
f" Best Lead: {self.lead_days}d (IC={self.lead_ic:.4f})" if self.is_leading else " Leading: No (synchronous/lagging)",
|
||||
f" → {self.conclusion}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class FactorValidator:
|
||||
"""
|
||||
Validates a factor's predictive power using standard quant metrics.
|
||||
|
||||
For each forward horizon (1d, 3d, 5d, 7d, 14d), computes:
|
||||
- IC (Spearman rank correlation)
|
||||
- ICIR (IC stability)
|
||||
- Hit Ratio (direction accuracy)
|
||||
- Quantile spread (top vs bottom bucket)
|
||||
- Lead-lag profile
|
||||
|
||||
A factor is valid if IC > 0.03 and ICIR > 0.5.
|
||||
For regime factors, also check regime_validator.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def validate(self, factor_name: str, factor_scores: pd.Series,
|
||||
forward_returns: dict[str, pd.Series]) -> FactorReport:
|
||||
"""
|
||||
Args:
|
||||
factor_name: Human-readable name
|
||||
factor_scores: Series indexed by date, values 0-100
|
||||
forward_returns: Dict of horizon → Series indexed by date (e.g. "1d" → returns)
|
||||
"""
|
||||
report = FactorReport(factor_name)
|
||||
|
||||
# Align series to common dates
|
||||
common_idx = factor_scores.index
|
||||
for ret in forward_returns.values():
|
||||
common_idx = common_idx.intersection(ret.index)
|
||||
|
||||
if len(common_idx) < 30:
|
||||
report.conclusion = "INSUFFICIENT DATA (< 30 observations)"
|
||||
return report
|
||||
|
||||
f = factor_scores[common_idx]
|
||||
report.n_observations = len(common_idx)
|
||||
|
||||
# Test against 7d forward returns (primary horizon)
|
||||
primary_ret = forward_returns.get("7d")
|
||||
if primary_ret is None:
|
||||
# Use first available
|
||||
primary_ret = list(forward_returns.values())[0]
|
||||
|
||||
r = primary_ret[common_idx]
|
||||
|
||||
# IC
|
||||
ic = information_coefficient(f, r)
|
||||
report.ic_mean = round(ic, 4)
|
||||
|
||||
# Rolling IC for ICIR
|
||||
rolling_ics = []
|
||||
for i in range(30, len(f)):
|
||||
ic_i = information_coefficient(f.iloc[:i], r.iloc[:i])
|
||||
rolling_ics.append(ic_i)
|
||||
ic_series = pd.Series(rolling_ics)
|
||||
report.ic_std = round(ic_series.std(), 4)
|
||||
report.icir = round(icir(ic_series), 2)
|
||||
|
||||
# Hit ratio
|
||||
report.hit_ratio = round(hit_ratio(f, r), 4)
|
||||
|
||||
# Quantile spread
|
||||
report.quantile_spread = round(quantile_spread(f, r), 4)
|
||||
|
||||
# Lead-lag
|
||||
lead = lead_lag_ic(f, r, max_lag=14)
|
||||
report.is_leading = lead["is_leading"]
|
||||
report.lead_days = lead["lead_days"]
|
||||
report.lead_ic = round(lead["best_ic"], 4)
|
||||
|
||||
# Conclusion
|
||||
if abs(report.ic_mean) > 0.05 and report.icir > 1.0:
|
||||
report.conclusion = "STRONG: significant predictive power"
|
||||
elif abs(report.ic_mean) > 0.03 and report.icir > 0.5:
|
||||
report.conclusion = "VALID: moderate predictive power"
|
||||
elif abs(report.ic_mean) < 0.02:
|
||||
report.conclusion = "CONFIRMING: describes current state, not predictive"
|
||||
else:
|
||||
report.conclusion = "WEAK: borderline, monitor or downweight"
|
||||
|
||||
return report
|
||||
|
||||
def validate_from_db(self, factor_name: str,
|
||||
score_query: str,
|
||||
horizon_days: int = 7) -> FactorReport:
|
||||
"""
|
||||
Convenience: load scores from DB and OHLCV returns, then validate.
|
||||
|
||||
score_query: SQL that returns (date, score) pairs.
|
||||
"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
scores_df = pd.read_sql_query(score_query, conn)
|
||||
if scores_df.empty:
|
||||
conn.close()
|
||||
r = FactorReport(factor_name)
|
||||
r.conclusion = "NO DATA"
|
||||
return r
|
||||
|
||||
scores_df["date"] = pd.to_datetime(scores_df["date"])
|
||||
scores = scores_df.set_index("date")["score"]
|
||||
|
||||
# Load forward returns from OHLCV
|
||||
ohlcv = pd.read_sql_query(
|
||||
"SELECT date, close FROM ohlcv_daily WHERE symbol='BTC/USDT:USDT' ORDER BY date",
|
||||
conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
ohlcv["date"] = pd.to_datetime(ohlcv["date"])
|
||||
ohlcv = ohlcv.set_index("date")
|
||||
ohlcv["ret"] = ohlcv["close"].pct_change().shift(-1) # forward 1d
|
||||
|
||||
# Build forward returns for multiple horizons
|
||||
forward = {}
|
||||
for h in [1, 3, 5, 7, 14]:
|
||||
forward[str(h) + "d"] = ohlcv["close"].pct_change(periods=h).shift(-h)
|
||||
|
||||
return self.validate(factor_name, scores, forward)
|
||||
@@ -1,192 +0,0 @@
|
||||
"""
|
||||
validation/metrics.py — Shared statistical metrics for factor and regime validation.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from scipy import stats
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def information_coefficient(factor: pd.Series, forward_returns: pd.Series) -> float:
|
||||
"""Spearman rank IC between factor values and forward returns."""
|
||||
mask = factor.notna() & forward_returns.notna()
|
||||
if mask.sum() < 10:
|
||||
return 0.0
|
||||
ic, _ = stats.spearmanr(factor[mask], forward_returns[mask])
|
||||
return float(ic) if not np.isnan(ic) else 0.0
|
||||
|
||||
|
||||
def icir(ic_series: pd.Series) -> float:
|
||||
"""Information Coefficient IR = mean(IC) / std(IC)."""
|
||||
if len(ic_series) < 5 or ic_series.std() == 0:
|
||||
return 0.0
|
||||
return float(ic_series.mean() / ic_series.std())
|
||||
|
||||
|
||||
def hit_ratio(factor: pd.Series, forward_returns: pd.Series) -> float:
|
||||
"""Fraction of times factor direction matches return direction."""
|
||||
mask = factor.notna() & forward_returns.notna()
|
||||
if mask.sum() < 10:
|
||||
return 0.5
|
||||
# Compare sign of factor deviation from median vs sign of returns
|
||||
factor_median = factor[mask].median()
|
||||
factor_sign = np.sign(factor[mask] - factor_median)
|
||||
return_sign = np.sign(forward_returns[mask])
|
||||
return float((factor_sign == return_sign).mean())
|
||||
|
||||
|
||||
def quantile_spread(factor: pd.Series, forward_returns: pd.Series,
|
||||
n_quantiles: int = 5) -> float:
|
||||
"""Top vs bottom quantile return spread (分层回测)."""
|
||||
mask = factor.notna() & forward_returns.notna()
|
||||
if mask.sum() < n_quantiles * 3:
|
||||
return 0.0
|
||||
f = factor[mask]
|
||||
r = forward_returns[mask]
|
||||
labels = pd.qcut(f, n_quantiles, labels=False, duplicates="drop")
|
||||
top_ret = r[labels == labels.max()].mean()
|
||||
bot_ret = r[labels == labels.min()].mean()
|
||||
return float(top_ret - bot_ret)
|
||||
|
||||
|
||||
def lead_lag_ic(factor: pd.Series, returns: pd.Series,
|
||||
max_lag: int = 14) -> dict:
|
||||
"""Find the best leading/trailing relationship by computing IC at each lag."""
|
||||
results = {}
|
||||
for lag in range(-max_lag, max_lag + 1):
|
||||
if lag < 0:
|
||||
shifted = factor.shift(abs(lag))
|
||||
ic = information_coefficient(shifted, returns)
|
||||
results[f"lead_{abs(lag)}d"] = ic
|
||||
elif lag > 0:
|
||||
shifted = returns.shift(lag)
|
||||
ic = information_coefficient(factor, shifted)
|
||||
results[f"lag_{lag}d"] = ic
|
||||
else:
|
||||
ic = information_coefficient(factor, returns)
|
||||
results["sync"] = ic
|
||||
|
||||
# Find best lead period
|
||||
lead_ics = {k: v for k, v in results.items() if k.startswith("lead_")}
|
||||
best_lead = max(lead_ics, key=lead_ics.get) if lead_ics else "sync"
|
||||
best_ic = lead_ics.get(best_lead, results.get("sync", 0))
|
||||
|
||||
return {
|
||||
"best_lead": best_lead,
|
||||
"best_ic": best_ic,
|
||||
"ic_curve": results,
|
||||
"is_leading": best_lead.startswith("lead_") and abs(best_ic) > 0.03,
|
||||
"lead_days": int(best_lead.split("_")[1].rstrip("d")) if best_lead.startswith("lead_") else 0,
|
||||
}
|
||||
|
||||
|
||||
def mutual_information(factor: pd.Series, labels: pd.Series,
|
||||
n_bins: int = 10) -> float:
|
||||
"""Mutual information between factor (binned) and discrete regime labels."""
|
||||
mask = factor.notna() & labels.notna()
|
||||
if mask.sum() < 20:
|
||||
return 0.0
|
||||
f = factor[mask]
|
||||
l = labels[mask]
|
||||
try:
|
||||
f_binned = pd.qcut(f, n_bins, labels=False, duplicates="drop")
|
||||
except ValueError:
|
||||
f_binned = pd.cut(f, n_bins, labels=False)
|
||||
mi = 0.0
|
||||
for fi in range(n_bins):
|
||||
p_f = (f_binned == fi).mean()
|
||||
if p_f == 0:
|
||||
continue
|
||||
for li in l.unique():
|
||||
p_l = (l == li).mean()
|
||||
p_joint = ((f_binned == fi) & (l == li)).mean()
|
||||
if p_joint > 0:
|
||||
mi += p_joint * np.log(p_joint / (p_f * p_l))
|
||||
return float(mi)
|
||||
|
||||
|
||||
def kl_divergence(factor: pd.Series, labels: pd.Series,
|
||||
regime_a: str, regime_b: str, n_bins: int = 10) -> float:
|
||||
"""KL divergence between factor distributions in two regimes."""
|
||||
mask_a = (labels == regime_a) & factor.notna()
|
||||
mask_b = (labels == regime_b) & factor.notna()
|
||||
if mask_a.sum() < 10 or mask_b.sum() < 10:
|
||||
return 0.0
|
||||
try:
|
||||
hist_a, edges = np.histogram(factor[mask_a], bins=n_bins, density=True)
|
||||
hist_b, _ = np.histogram(factor[mask_b], bins=edges, density=True)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
hist_a = np.clip(hist_a, 1e-10, None)
|
||||
hist_b = np.clip(hist_b, 1e-10, None)
|
||||
return float((hist_a * np.log(hist_a / hist_b)).sum())
|
||||
|
||||
|
||||
def anova_f_score(factor: pd.Series, labels: pd.Series) -> float:
|
||||
"""ANOVA F-statistic: how well factor separates different regimes."""
|
||||
mask = factor.notna() & labels.notna()
|
||||
if mask.sum() < 20:
|
||||
return 0.0
|
||||
groups = [factor[mask][labels[mask] == lbl] for lbl in labels[mask].unique()]
|
||||
groups = [g for g in groups if len(g) > 1]
|
||||
if len(groups) < 2:
|
||||
return 0.0
|
||||
f_stat, _ = stats.f_oneway(*groups)
|
||||
return float(f_stat) if not np.isnan(f_stat) else 0.0
|
||||
|
||||
|
||||
def transition_matrix(labels: pd.Series) -> pd.DataFrame:
|
||||
"""Compute Markov transition matrix from regime sequence."""
|
||||
unique = sorted(labels.dropna().unique())
|
||||
n = len(unique)
|
||||
matrix = np.zeros((n, n))
|
||||
seq = labels.dropna().values
|
||||
for i in range(len(seq) - 1):
|
||||
from_idx = unique.index(seq[i])
|
||||
to_idx = unique.index(seq[i + 1])
|
||||
matrix[from_idx][to_idx] += 1
|
||||
|
||||
# Row-normalize
|
||||
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||
row_sums[row_sums == 0] = 1
|
||||
matrix = matrix / row_sums
|
||||
|
||||
return pd.DataFrame(matrix, index=unique, columns=unique)
|
||||
|
||||
|
||||
def regime_duration_stats(labels: pd.Series) -> dict:
|
||||
"""Compute average duration, flip rate, state entropy for regime sequence."""
|
||||
seq = labels.dropna().values
|
||||
if len(seq) < 2:
|
||||
return {"avg_duration": 0, "flip_rate": 0, "state_entropy": 0, "n_days": len(seq)}
|
||||
|
||||
# Count durations
|
||||
durations = []
|
||||
current = seq[0]
|
||||
count = 1
|
||||
flips = 0
|
||||
for i in range(1, len(seq)):
|
||||
if seq[i] == current:
|
||||
count += 1
|
||||
else:
|
||||
durations.append(count)
|
||||
current = seq[i]
|
||||
count = 1
|
||||
flips += 1
|
||||
durations.append(count)
|
||||
|
||||
avg_dur = float(np.mean(durations)) if durations else 0
|
||||
flip_rate = flips / len(seq)
|
||||
|
||||
# State entropy
|
||||
_, counts = np.unique(seq, return_counts=True)
|
||||
probs = counts / counts.sum()
|
||||
entropy = float(-(probs * np.log2(probs + 1e-10)).sum())
|
||||
|
||||
return {
|
||||
"avg_duration": round(avg_dur, 1),
|
||||
"flip_rate": round(flip_rate, 3),
|
||||
"state_entropy": round(entropy, 3),
|
||||
"n_days": len(seq),
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"""
|
||||
validation/regime_validator.py — Validates factors as regime separators.
|
||||
|
||||
Tests: Mutual Information, KL Divergence, ANOVA F-score.
|
||||
Answers: "Does this factor distinguish different market regimes?"
|
||||
|
||||
Key insight: a factor may have low IC (poor return predictor) but high
|
||||
regime separation (good regime classifier). Breadth is the prime example.
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import config
|
||||
from .metrics import (
|
||||
mutual_information, kl_divergence, anova_f_score,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RegimeReport:
|
||||
"""Structured report for regime separation validation."""
|
||||
|
||||
def __init__(self, factor_name: str):
|
||||
self.factor_name = factor_name
|
||||
self.mutual_info: float = 0.0
|
||||
self.anova_f: float = 0.0
|
||||
self.kl_pairs: dict = {} # (regime_a, regime_b) → KL divergence
|
||||
self.best_separates: list[str] = []
|
||||
self.separation_score: float = 0.0
|
||||
self.is_regime_factor: bool = False
|
||||
self.conclusion: str = ""
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Factor: {self.factor_name}",
|
||||
f" Mutual Information: {self.mutual_info:.4f}",
|
||||
f" ANOVA F: {self.anova_f:.1f}",
|
||||
f" Best separates: {', '.join(self.best_separates) if self.best_separates else 'none'}",
|
||||
f" Regime Factor: {'YES' if self.is_regime_factor else 'No'}",
|
||||
f" → {self.conclusion}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class RegimeValidator:
|
||||
"""
|
||||
Validates a factor's ability to separate different market regimes.
|
||||
|
||||
A good regime factor has:
|
||||
- Mutual Information > 0.1
|
||||
- KL Divergence between regimes > 0.5
|
||||
- ANOVA F-score high
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def validate(self, factor_name: str, factor_scores: pd.Series,
|
||||
regime_labels: pd.Series) -> RegimeReport:
|
||||
"""
|
||||
Args:
|
||||
factor_name: Human-readable name
|
||||
factor_scores: Series indexed by date, values 0-100
|
||||
regime_labels: Series indexed by date, values = 'TREND'/'RANGE'/'PANIC'
|
||||
"""
|
||||
report = RegimeReport(factor_name)
|
||||
|
||||
# Align
|
||||
common_idx = factor_scores.index.intersection(regime_labels.index)
|
||||
if len(common_idx) < 30:
|
||||
report.conclusion = "INSUFFICIENT DATA"
|
||||
return report
|
||||
|
||||
f = factor_scores[common_idx]
|
||||
labels = regime_labels[common_idx]
|
||||
|
||||
# Mutual Information
|
||||
report.mutual_info = round(mutual_information(f, labels), 4)
|
||||
|
||||
# ANOVA
|
||||
report.anova_f = round(anova_f_score(f, labels), 1)
|
||||
|
||||
# KL Divergence between each pair of regimes
|
||||
unique_regimes = sorted(labels.unique())
|
||||
for i, ra in enumerate(unique_regimes):
|
||||
for rb in unique_regimes[i + 1:]:
|
||||
kl = kl_divergence(f, labels, ra, rb)
|
||||
report.kl_pairs[f"{ra}↔{rb}"] = round(kl, 4)
|
||||
|
||||
# Best separation
|
||||
if report.kl_pairs:
|
||||
sorted_pairs = sorted(report.kl_pairs, key=report.kl_pairs.get, reverse=True)
|
||||
report.best_separates = sorted_pairs[:2]
|
||||
|
||||
# Separation score (0-1 composite)
|
||||
mi_norm = min(report.mutual_info / 0.5, 1.0)
|
||||
kl_avg = np.mean(list(report.kl_pairs.values())) if report.kl_pairs else 0
|
||||
kl_norm = min(kl_avg / 1.0, 1.0)
|
||||
report.separation_score = round(0.5 * mi_norm + 0.5 * kl_norm, 2)
|
||||
|
||||
# Is this a good regime factor?
|
||||
report.is_regime_factor = (
|
||||
report.mutual_info > 0.1 and
|
||||
kl_avg > 0.5
|
||||
)
|
||||
|
||||
if report.separation_score > 0.8:
|
||||
report.conclusion = "EXCELLENT regime separator"
|
||||
elif report.separation_score > 0.5:
|
||||
report.conclusion = "GOOD regime separator"
|
||||
elif report.separation_score > 0.3:
|
||||
report.conclusion = "MODERATE — some regime separation"
|
||||
else:
|
||||
report.conclusion = "WEAK regime separator"
|
||||
|
||||
return report
|
||||
|
||||
def validate_from_db(self, factor_name: str,
|
||||
score_query: str) -> RegimeReport:
|
||||
"""Load scores and regime labels from DB, then validate."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
|
||||
scores_df = pd.read_sql_query(score_query, conn)
|
||||
regimes_df = pd.read_sql_query(
|
||||
"SELECT date, regime FROM regime_history", conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
if scores_df.empty or regimes_df.empty:
|
||||
r = RegimeReport(factor_name)
|
||||
r.conclusion = "NO DATA"
|
||||
return r
|
||||
|
||||
scores = scores_df.set_index("date")["score"]
|
||||
regimes = regimes_df.set_index("date")["regime"]
|
||||
|
||||
return self.validate(factor_name, scores, regimes)
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
validation/reporter.py — Aggregates all validation reports into a unified summary.
|
||||
|
||||
Used by: python main.py validate
|
||||
"""
|
||||
|
||||
from datetime import date as Date
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from .factor_validator import FactorValidator, FactorReport
|
||||
from .regime_validator import RegimeValidator, RegimeReport
|
||||
from .transition_validator import TransitionValidator, TransitionReport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidationReporter:
|
||||
"""
|
||||
Orchestrates full validation pipeline:
|
||||
|
||||
1. Factor validation (IC, ICIR, Hit Ratio) for each factor
|
||||
2. Regime validation (MI, KL, ANOVA) for each factor
|
||||
3. Transition validation (stability, flip rate)
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
from config import config
|
||||
self.db_path = db_path or config.db_path
|
||||
self.factor_validator = FactorValidator(self.db_path)
|
||||
self.regime_validator = RegimeValidator(self.db_path)
|
||||
self.transition_validator = TransitionValidator(self.db_path)
|
||||
|
||||
def run_all(self) -> str:
|
||||
"""Run all validations and return a formatted report string."""
|
||||
lines = []
|
||||
lines.append("=" * 70)
|
||||
lines.append(f" ChanMacro Validation Report — {Date.today()}")
|
||||
lines.append("=" * 70)
|
||||
|
||||
# ── Factor Validation ──────────────────────────
|
||||
lines.append("")
|
||||
lines.append("─" * 50)
|
||||
lines.append(" FACTOR VALIDATION (Predictive Power)")
|
||||
lines.append("─" * 50)
|
||||
|
||||
factor_queries = {
|
||||
"Price Structure": "SELECT date, score FROM ohlcv_daily WHERE ema20 IS NOT NULL",
|
||||
"Breadth": """
|
||||
SELECT bd.date,
|
||||
(bd.advance_top50*1.0/(bd.advance_top50+bd.decline_top50+1)*100*0.30
|
||||
+ bd.above_ema20_top50*1.0/50*100*0.35
|
||||
+ bd.new_highs_20d_top50*1.0/50*100*0.20
|
||||
+ 50*0.15) as score
|
||||
FROM breadth_daily bd
|
||||
""",
|
||||
}
|
||||
|
||||
factor_reports: list[FactorReport] = []
|
||||
for name, query in factor_queries.items():
|
||||
try:
|
||||
report = self.factor_validator.validate_from_db(name, query)
|
||||
factor_reports.append(report)
|
||||
lines.append(report.summary())
|
||||
lines.append("")
|
||||
except Exception as e:
|
||||
logger.warning(f"Factor validation failed for {name}: {e}")
|
||||
|
||||
# ── Regime Validation ──────────────────────────
|
||||
lines.append("─" * 50)
|
||||
lines.append(" REGIME VALIDATION (Regime Separation)")
|
||||
lines.append("─" * 50)
|
||||
|
||||
regime_reports: list[RegimeReport] = []
|
||||
for name, query in factor_queries.items():
|
||||
try:
|
||||
report = self.regime_validator.validate_from_db(name, query)
|
||||
regime_reports.append(report)
|
||||
lines.append(report.summary())
|
||||
lines.append("")
|
||||
except Exception as e:
|
||||
logger.warning(f"Regime validation failed for {name}: {e}")
|
||||
|
||||
# ── Transition Validation ──────────────────────
|
||||
lines.append("─" * 50)
|
||||
lines.append(" TRANSITION VALIDATION (Regime Stability)")
|
||||
lines.append("─" * 50)
|
||||
|
||||
try:
|
||||
t_report = self.transition_validator.validate_from_db()
|
||||
lines.append(t_report.summary())
|
||||
except Exception as e:
|
||||
logger.warning(f"Transition validation failed: {e}")
|
||||
|
||||
# ── Summary ────────────────────────────────────
|
||||
lines.append("")
|
||||
lines.append("=" * 70)
|
||||
lines.append(" SUMMARY")
|
||||
lines.append("=" * 70)
|
||||
|
||||
# Factor ranking by IC
|
||||
if factor_reports:
|
||||
ranked = sorted(factor_reports, key=lambda r: abs(r.ic_mean), reverse=True)
|
||||
lines.append(" Factor Ranking (by |IC|):")
|
||||
for i, r in enumerate(ranked):
|
||||
tag = "★★★" if abs(r.ic_mean) > 0.05 else "★★" if abs(r.ic_mean) > 0.03 else "★"
|
||||
lines.append(f" {i+1}. {r.factor_name:20s} IC={r.ic_mean:+.4f} {tag} {r.conclusion}")
|
||||
|
||||
# Regime factor ranking
|
||||
if regime_reports:
|
||||
ranked_r = sorted(regime_reports, key=lambda r: r.separation_score, reverse=True)
|
||||
lines.append("")
|
||||
lines.append(" Regime Factor Ranking (by Separation Score):")
|
||||
for i, r in enumerate(ranked_r):
|
||||
lines.append(f" {i+1}. {r.factor_name:20s} Score={r.separation_score:.2f} {r.conclusion}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 70)
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
validation/transition_validator.py — Validates regime stability.
|
||||
|
||||
Tests: Transition matrix, average duration, flip rate, state entropy.
|
||||
Answers: "Does the regime design produce stable, persistent states?"
|
||||
|
||||
Hard requirements:
|
||||
- avg_duration > 5 days
|
||||
- flip_rate < 15%
|
||||
- Fails → regime definition needs redesign.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import config
|
||||
from .metrics import transition_matrix, regime_duration_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TransitionReport:
|
||||
"""Structured report for regime stability validation."""
|
||||
|
||||
def __init__(self):
|
||||
self.avg_duration: float = 0.0
|
||||
self.flip_rate: float = 0.0
|
||||
self.state_entropy: float = 0.0
|
||||
self.n_days: int = 0
|
||||
self.transition_matrix: Optional[pd.DataFrame] = None
|
||||
self.persistence_score: float = 0.0
|
||||
self.is_stable: bool = False
|
||||
self.conclusion: str = ""
|
||||
self.warnings: list[str] = []
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
f"Regime Stability (N={self.n_days} days)",
|
||||
f" Avg Duration: {self.avg_duration:.1f} days (need > {config.regime_min_avg_duration})",
|
||||
f" Flip Rate: {self.flip_rate:.1%} (need < {config.regime_max_flip_rate:.0%})",
|
||||
f" State Entropy: {self.state_entropy:.3f}",
|
||||
f" Persistence Score: {self.persistence_score:.2f}",
|
||||
f" Stable: {'YES' if self.is_stable else 'NO — redesign needed'}",
|
||||
]
|
||||
if self.warnings:
|
||||
lines.append(f" Warnings: {'; '.join(self.warnings)}")
|
||||
if self.transition_matrix is not None:
|
||||
lines.append(f" Transition Matrix:\n{self.transition_matrix.to_string()}")
|
||||
lines.append(f" → {self.conclusion}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class TransitionValidator:
|
||||
"""
|
||||
Validates regime temporal stability.
|
||||
|
||||
Regime must persist — not flip daily.
|
||||
If flip_rate > 20% or avg_duration < 3 days → regime definition failed.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Optional[str] = None):
|
||||
self.db_path = db_path or config.db_path
|
||||
|
||||
def validate(self, regime_labels: pd.Series) -> TransitionReport:
|
||||
"""Validate a regime sequence for stability."""
|
||||
report = TransitionReport()
|
||||
report.n_days = len(regime_labels)
|
||||
|
||||
if len(regime_labels) < 30:
|
||||
report.conclusion = "INSUFFICIENT DATA (< 30 days)"
|
||||
return report
|
||||
|
||||
# Duration stats
|
||||
stats = regime_duration_stats(regime_labels)
|
||||
report.avg_duration = stats["avg_duration"]
|
||||
report.flip_rate = stats["flip_rate"]
|
||||
report.state_entropy = stats["state_entropy"]
|
||||
|
||||
# Transition matrix
|
||||
report.transition_matrix = transition_matrix(regime_labels)
|
||||
|
||||
# Persistence: how often does regime stay the same?
|
||||
diag = np.diag(report.transition_matrix.values)
|
||||
report.persistence_score = round(float(np.mean(diag)), 2)
|
||||
|
||||
# Stability check
|
||||
report.is_stable = (
|
||||
report.avg_duration >= config.regime_min_avg_duration and
|
||||
report.flip_rate <= config.regime_max_flip_rate
|
||||
)
|
||||
|
||||
# Warnings
|
||||
if report.avg_duration < 3:
|
||||
report.warnings.append(f"CRITICAL: avg duration={report.avg_duration:.1f}d — regime flips too fast")
|
||||
elif report.avg_duration < config.regime_min_avg_duration:
|
||||
report.warnings.append(f"WARNING: avg duration={report.avg_duration:.1f}d < {config.regime_min_avg_duration}")
|
||||
|
||||
if report.flip_rate > 0.20:
|
||||
report.warnings.append(f"CRITICAL: flip rate={report.flip_rate:.1%} — regime unstable")
|
||||
elif report.flip_rate > config.regime_max_flip_rate:
|
||||
report.warnings.append(f"WARNING: flip rate={report.flip_rate:.1%} > {config.regime_max_flip_rate:.0%}")
|
||||
|
||||
if report.state_entropy > 2.0:
|
||||
report.warnings.append(f"NOTE: high state entropy={report.state_entropy:.2f}, regimes may be too fine-grained")
|
||||
|
||||
if report.is_stable:
|
||||
report.conclusion = "PASS: regime design is stable"
|
||||
else:
|
||||
report.conclusion = "FAIL: regime definition needs adjustment"
|
||||
|
||||
return report
|
||||
|
||||
def validate_from_db(self) -> TransitionReport:
|
||||
"""Load regime history from DB and validate stability."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
df = pd.read_sql_query(
|
||||
"SELECT date, regime FROM regime_history ORDER BY date", conn
|
||||
)
|
||||
conn.close()
|
||||
|
||||
if df.empty:
|
||||
r = TransitionReport()
|
||||
r.conclusion = "NO DATA"
|
||||
return r
|
||||
|
||||
regimes = df.set_index("date")["regime"]
|
||||
return self.validate(regimes)
|
||||
@@ -1,158 +0,0 @@
|
||||
"""
|
||||
web/app.py — ChanMacro dashboard (Flask, port 8124).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from datetime import date as Date, timedelta
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
|
||||
from database import get_connection
|
||||
from config import config
|
||||
from scoring.price_structure import PriceStructureScorer
|
||||
from scoring.breadth_scorer import BreadthScorer
|
||||
from scoring.oi_matrix import OIMatrixScorer
|
||||
from scoring.volatility_regime import VolatilityRegimeScorer
|
||||
from regime_detector import RegimeDetector
|
||||
from models import MarketStateVector
|
||||
from expectancy.engine import BayesianExpectancyEngine
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def _build_state(target: Date):
|
||||
"""Shared: build MarketStateVector for a date."""
|
||||
ps = PriceStructureScorer().compute(target)
|
||||
br = BreadthScorer().compute(target)
|
||||
oi = OIMatrixScorer().compute(target)
|
||||
vol = VolatilityRegimeScorer().compute(target)
|
||||
|
||||
detector = RegimeDetector()
|
||||
detector.load_state(config.db_path)
|
||||
r = detector.detect(ps.score, br.breadth_top50, vol.vol_regime.value, target)
|
||||
|
||||
state = MarketStateVector(
|
||||
date=target, regime=r.regime, regime_confidence=r.confidence,
|
||||
regime_version=r.regime_version, regime_maturity_score=r.maturity_score,
|
||||
breadth_top20=br.breadth_top20, breadth_top30=br.breadth_top30,
|
||||
breadth_top50=br.breadth_top50, breadth_bucket=br.breadth_bucket,
|
||||
breadth_divergence=br.breadth_divergence,
|
||||
oi_state=oi.oi_state, volatility_regime=vol.vol_regime,
|
||||
price_structure_score=ps, breadth_score=br,
|
||||
oi_matrix_score=oi, volatility_regime_score=vol,
|
||||
)
|
||||
state.market_state_hash = state.compute_hash()
|
||||
return state
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def dashboard():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/state")
|
||||
def api_state():
|
||||
"""Current market state with all factor scores."""
|
||||
try:
|
||||
target = Date.today()
|
||||
state = _build_state(target)
|
||||
return jsonify({
|
||||
"date": str(state.date),
|
||||
"regime": state.regime.value,
|
||||
"regime_confidence": state.regime_confidence,
|
||||
"regime_maturity": state.regime_maturity_score,
|
||||
"breadth": {
|
||||
"score": state.breadth_score.score,
|
||||
"bucket": state.breadth_bucket.value,
|
||||
"top20": state.breadth_top20,
|
||||
"top30": state.breadth_top30,
|
||||
"top50": state.breadth_top50,
|
||||
"divergence": state.breadth_divergence,
|
||||
"narrative": state.breadth_score.narrative,
|
||||
},
|
||||
"oi_state": state.oi_state.value,
|
||||
"oi_score": state.oi_matrix_score.score,
|
||||
"oi_narrative": state.oi_matrix_score.narrative,
|
||||
"volatility": state.volatility_regime.value,
|
||||
"price_structure": {
|
||||
"score": state.price_structure_score.score,
|
||||
"trend": state.price_structure_score.trend_strength,
|
||||
"vol_comp": state.price_structure_score.volatility_compression,
|
||||
"momentum": state.price_structure_score.momentum,
|
||||
"label": state.price_structure_score.label,
|
||||
"narrative": state.price_structure_score.narrative,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
"""Regime and factor score history."""
|
||||
days = request.args.get("days", 60, type=int)
|
||||
conn = get_connection()
|
||||
|
||||
# Regime history
|
||||
regimes = conn.execute(
|
||||
"SELECT date, regime, confidence, maturity_score FROM regime_history ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
# Breadth history
|
||||
breadth = conn.execute(
|
||||
"SELECT date, advance_top50, decline_top50, above_ema20_top50 FROM breadth_daily ORDER BY date DESC LIMIT ?",
|
||||
(days,)
|
||||
).fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return jsonify({
|
||||
"regimes": [{"date": r["date"], "regime": r["regime"],
|
||||
"confidence": r["confidence"], "maturity": r["maturity_score"]}
|
||||
for r in reversed(regimes)],
|
||||
"breadth": [{"date": b["date"], "advance": b["advance_top50"],
|
||||
"decline": b["decline_top50"], "above_ema20": b["above_ema20_top50"]}
|
||||
for b in reversed(breadth)],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/expectancy")
|
||||
def api_expectancy():
|
||||
"""Query signal expectancy."""
|
||||
signal = request.args.get("signal", "B3")
|
||||
try:
|
||||
target = Date.today()
|
||||
state = _build_state(target)
|
||||
engine = BayesianExpectancyEngine(level_min_samples=5)
|
||||
report = engine.estimate(state, signal_type=signal, target_date=target)
|
||||
|
||||
layers = []
|
||||
for l in report.layers:
|
||||
layers.append({
|
||||
"name": l.name,
|
||||
"samples": l.samples,
|
||||
"effective_samples": l.effective_samples,
|
||||
"raw_winrate": l.raw_winrate,
|
||||
"posterior_winrate": l.posterior_winrate,
|
||||
"avg_return": l.avg_return,
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"signal": signal,
|
||||
"final_estimate": report.final_estimate,
|
||||
"sufficiency": report.sufficiency.value,
|
||||
"source": report.source,
|
||||
"avg_return_7d": report.avg_return_7d,
|
||||
"profit_factor": report.profit_factor,
|
||||
"max_adverse": report.max_adverse_excursion,
|
||||
"layers": layers,
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8124, debug=True)
|
||||
@@ -1,171 +0,0 @@
|
||||
// dashboard.js — ChanMacro dashboard
|
||||
|
||||
let regimeChart = null, breadthChart = null;
|
||||
|
||||
const REGIME_COLORS = { TREND: "#3fb950", RANGE: "#d29922", PANIC: "#f85149" };
|
||||
const BUCKET_CLASS = { EXTREME: "bucket-EXTREME", STRONG: "bucket-STRONG",
|
||||
NORMAL: "bucket-NORMAL", WEAK: "bucket-WEAK", PANIC: "bucket-PANIC" };
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const r = await fetch("/api/state");
|
||||
const d = await r.json();
|
||||
if (d.error) { document.getElementById("db-status").textContent = d.error; return; }
|
||||
|
||||
document.getElementById("db-status").textContent = "✓ " + d.date;
|
||||
document.getElementById("update-time").textContent = "更新于 " + new Date().toLocaleTimeString();
|
||||
|
||||
// Hero
|
||||
const regime = d.regime;
|
||||
document.getElementById("hero-regime").textContent = regime === "TREND" ? "趋势" : regime === "RANGE" ? "震荡" : "恐慌";
|
||||
document.getElementById("hero-regime").className = "hero-regime regime-" + regime;
|
||||
document.getElementById("hero-badge").textContent = regime;
|
||||
document.getElementById("hero-badge").className = "badge-regime badge-" + regime;
|
||||
document.getElementById("hero-conf").textContent = (d.regime_confidence * 100).toFixed(0) + "%";
|
||||
document.getElementById("hero-maturity").textContent = d.regime_maturity.toFixed(0) + "/100";
|
||||
|
||||
// Factors
|
||||
document.getElementById("f-price").textContent = d.price_structure.score.toFixed(0);
|
||||
document.getElementById("f-price").style.color =
|
||||
d.price_structure.score >= 60 ? "#3fb950" : d.price_structure.score >= 40 ? "#d29922" : "#f85149";
|
||||
document.getElementById("f-price-sub").textContent = d.price_structure.label;
|
||||
document.getElementById("f-price-narr").textContent = d.price_structure.narrative;
|
||||
|
||||
const b = d.breadth;
|
||||
document.getElementById("f-breadth").textContent = b.score.toFixed(0);
|
||||
document.getElementById("f-breadth").setAttribute("style",
|
||||
"color: " + (b.bucket === "EXTREME" || b.bucket === "STRONG" ? "#3fb950" :
|
||||
b.bucket === "WEAK" || b.bucket === "PANIC" ? "#f85149" :
|
||||
b.bucket === "NORMAL" ? "#d29922" : "#e6edf3"));
|
||||
document.getElementById("f-breadth-sub").textContent =
|
||||
`${b.bucket} · T20=${b.top20.toFixed(0)} T50=${b.top50.toFixed(0)} div=${b.divergence > 0 ? "+" : ""}${b.divergence.toFixed(0)}`;
|
||||
document.getElementById("f-breadth-narr").textContent = b.narrative;
|
||||
|
||||
document.getElementById("f-oi").textContent = d.oi_state;
|
||||
document.getElementById("f-oi").style.color =
|
||||
d.oi_state === "New Longs" ? "#3fb950" : d.oi_state === "New Shorts" ? "#f85149" :
|
||||
d.oi_state === "Short Covering" ? "#d29922" : d.oi_state === "Long Exit" ? "#f85149" : "#e6edf3";
|
||||
document.getElementById("f-oi-sub").textContent = `分数: ${d.oi_score.toFixed(0)}`;
|
||||
document.getElementById("f-oi-narr").textContent = d.oi_narrative;
|
||||
|
||||
document.getElementById("f-vol").textContent = d.volatility;
|
||||
document.getElementById("f-vol").style.color =
|
||||
d.volatility === "LOW_VOL" ? "#58a6ff" : d.volatility === "NORMAL_VOL" ? "#e6edf3" :
|
||||
d.volatility === "HIGH_VOL" ? "#d29922" : "#f85149";
|
||||
document.getElementById("f-vol-sub").textContent = `分数: ${d.price_structure.score.toFixed(0)}`;
|
||||
} catch (e) {
|
||||
document.getElementById("db-status").textContent = "连接失败";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const r = await fetch("/api/history?days=60");
|
||||
const d = await r.json();
|
||||
|
||||
// Regime chart
|
||||
const dates = d.regimes.map(x => x.date);
|
||||
const regimes = d.regimes.map(x => x.regime);
|
||||
const colors = regimes.map(r => REGIME_COLORS[r] || "#8b949e");
|
||||
|
||||
if (regimeChart) regimeChart.destroy();
|
||||
const ctx1 = document.getElementById("chart-regime").getContext("2d");
|
||||
regimeChart = new Chart(ctx1, {
|
||||
type: "bar",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [{
|
||||
label: "置信度",
|
||||
data: d.regimes.map(x => x.confidence * 100),
|
||||
backgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
borderRadius: 2,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => `${d.regimes[ctx.dataIndex].regime} · ${ctx.raw.toFixed(0)}%`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: "#8b949e", maxTicksLimit: 15, maxRotation: 45 } },
|
||||
y: { max: 100, ticks: { color: "#8b949e" } }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Breadth chart
|
||||
if (breadthChart) breadthChart.destroy();
|
||||
const ctx2 = document.getElementById("chart-breadth").getContext("2d");
|
||||
breadthChart = new Chart(ctx2, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: d.breadth.map(x => x.date),
|
||||
datasets: [
|
||||
{ label: "上涨", data: d.breadth.map(x => x.advance), borderColor: "#3fb950",
|
||||
backgroundColor: "rgba(63,185,80,0.1)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||
{ label: "下跌", data: d.breadth.map(x => x.decline), borderColor: "#f85149",
|
||||
backgroundColor: "rgba(248,81,73,0.1)", fill: true, tension: 0.3, pointRadius: 0 },
|
||||
{ label: ">EMA20", data: d.breadth.map(x => x.above_ema20), borderColor: "#58a6ff",
|
||||
borderDash: [4, 2], tension: 0.3, pointRadius: 0 },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: "#8b949e", usePointStyle: true, boxWidth: 8 } } },
|
||||
scales: {
|
||||
x: { ticks: { color: "#8b949e", maxTicksLimit: 15, maxRotation: 45 } },
|
||||
y: { max: 50, ticks: { color: "#8b949e" } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("History load failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExpectancy() {
|
||||
const signal = document.getElementById("exp-signal").value;
|
||||
try {
|
||||
const r = await fetch(`/api/expectancy?signal=${signal}`);
|
||||
const d = await r.json();
|
||||
if (d.error) { document.getElementById("exp-layers").innerHTML = `<tr><td colspan="6">${d.error}</td></tr>`; return; }
|
||||
|
||||
document.getElementById("exp-sufficiency").textContent = d.sufficiency;
|
||||
document.getElementById("exp-sufficiency").className =
|
||||
"badge " + (d.sufficiency === "HIGH" ? "bg-success" : d.sufficiency === "MEDIUM" ? "bg-warning" :
|
||||
d.sufficiency === "LOW" ? "bg-danger" : "bg-secondary");
|
||||
|
||||
let html = "";
|
||||
for (const l of d.layers) {
|
||||
html += `<tr>
|
||||
<td>${l.name}</td>
|
||||
<td>${l.samples}</td>
|
||||
<td>${l.effective_samples.toFixed(0)}</td>
|
||||
<td>${l.raw_winrate ? (l.raw_winrate * 100).toFixed(1) + "%" : "—"}</td>
|
||||
<td><strong>${(l.posterior_winrate * 100).toFixed(1)}%</strong></td>
|
||||
<td>${l.avg_return ? (l.avg_return > 0 ? "+" : "") + l.avg_return.toFixed(1) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
}
|
||||
document.getElementById("exp-layers").innerHTML = html;
|
||||
|
||||
let summary = `最终估计: <strong>${(d.final_estimate * 100).toFixed(1)}%</strong>`;
|
||||
if (d.avg_return_7d) summary += ` · 平均收益: <strong>${d.avg_return_7d > 0 ? "+" : ""}${d.avg_return_7d.toFixed(1)}%</strong>`;
|
||||
if (d.profit_factor) summary += ` · 盈亏比: <strong>${d.profit_factor}</strong>`;
|
||||
document.getElementById("exp-summary").innerHTML = summary;
|
||||
} catch (e) {
|
||||
console.error("Expectancy load failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
loadState();
|
||||
loadHistory();
|
||||
loadExpectancy();
|
||||
@@ -1,144 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ChanMacro — 市场状态</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #e6edf3; --muted: #8b949e;
|
||||
--green: #3fb950; --red: #f85149; --orange: #d29922; --blue: #58a6ff; }
|
||||
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, sans-serif; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; }
|
||||
.hero-regime { font-size: 3rem; font-weight: 700; }
|
||||
.hero-conf { font-size: 1.2rem; color: var(--muted); }
|
||||
.factor-value { font-size: 2.2rem; font-weight: 700; color: var(--text); }
|
||||
.factor-label { color: var(--muted); font-size: 0.85rem; }
|
||||
.regime-TREND { color: var(--green); }
|
||||
.regime-RANGE { color: var(--orange); }
|
||||
.regime-PANIC { color: var(--red); }
|
||||
.bucket-EXTREME, .bucket-STRONG { color: var(--green); }
|
||||
.bucket-NORMAL { color: var(--orange); }
|
||||
.bucket-WEAK, .bucket-PANIC { color: var(--red); }
|
||||
.badge-regime { font-size: 0.85rem; padding: 4px 12px; border-radius: 20px; }
|
||||
.badge-TREND { background: #1a3a1a; color: var(--green); }
|
||||
.badge-RANGE { background: #3a2a0a; color: var(--orange); }
|
||||
.badge-PANIC { background: #3a0a0a; color: var(--red); }
|
||||
.narrative { color: var(--muted); font-size: 0.9rem; }
|
||||
canvas { max-height: 300px; }
|
||||
.text-muted { color: var(--muted) !important; }
|
||||
.table { color: var(--text); }
|
||||
.table-dark { --bs-table-color: var(--text); --bs-table-bg: var(--card); }
|
||||
.form-select { background-color: var(--card); color: var(--text); border-color: var(--border); }
|
||||
.btn-primary { background-color: var(--blue); border-color: var(--blue); }
|
||||
strong { color: var(--text); }
|
||||
small { color: var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container-fluid py-3 px-4">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4 class="mb-0">ChanMacro <span class="text-muted fs-6">市场状态</span></h4>
|
||||
<small class="text-muted" id="update-time"></small>
|
||||
</div>
|
||||
<div>
|
||||
<span class="badge bg-secondary" id="db-status">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero: Regime -->
|
||||
<div class="card p-4 mb-3 text-center">
|
||||
<div class="hero-conf mb-1">当前制度</div>
|
||||
<div class="hero-regime" id="hero-regime">—</div>
|
||||
<div>
|
||||
<span class="badge-regime" id="hero-badge">—</span>
|
||||
<span class="ms-2" style="color:#8b949e">置信度 <strong id="hero-conf" style="color:#e6edf3">—</strong></span>
|
||||
<span class="ms-2" style="color:#8b949e">成熟度 <strong id="hero-maturity" style="color:#e6edf3">—</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 Factor Cards -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">价格结构</div>
|
||||
<div class="factor-value" id="f-price">—</div>
|
||||
<div class="text-muted small" id="f-price-sub"></div>
|
||||
<div class="narrative mt-1" id="f-price-narr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">市场广度</div>
|
||||
<div class="factor-value" id="f-breadth">—</div>
|
||||
<div class="text-muted small" id="f-breadth-sub"></div>
|
||||
<div class="narrative mt-1" id="f-breadth-narr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">OI 状态</div>
|
||||
<div class="factor-value fs-4" id="f-oi">—</div>
|
||||
<div class="text-muted small" id="f-oi-sub"></div>
|
||||
<div class="narrative mt-1" id="f-oi-narr"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card p-3 h-100">
|
||||
<div class="factor-label">波动率</div>
|
||||
<div class="factor-value" id="f-vol">—</div>
|
||||
<div class="text-muted small" id="f-vol-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3">
|
||||
<h6 class="mb-3">制度历史</h6>
|
||||
<canvas id="chart-regime"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card p-3">
|
||||
<h6 class="mb-3">市场广度</h6>
|
||||
<canvas id="chart-breadth"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expectancy -->
|
||||
<div class="card p-3">
|
||||
<h6 class="mb-3">信号期望查询</h6>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" id="exp-signal">
|
||||
<option value="B3">B3 (三买)</option><option value="B2">B2 (二买)</option><option value="B1">B1 (一买)</option>
|
||||
<option value="S3">S3 (三卖)</option><option value="S2">S2 (二卖)</option><option value="S1">S1 (一卖)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary" onclick="loadExpectancy()">查询</button>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="badge bg-secondary" id="exp-sufficiency">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive mt-2">
|
||||
<table class="table table-sm table-dark mb-0" style="--bs-table-bg:#161b22">
|
||||
<thead><tr><th>层级</th><th>样本</th><th>有效样本</th><th>原始胜率</th><th>后验胜率</th><th>平均收益</th></tr></thead>
|
||||
<tbody id="exp-layers"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small" id="exp-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,292 +0,0 @@
|
||||
"""
|
||||
中枢结构特征提取 + 标签化
|
||||
Market Structure Dataset Builder — Phase 1
|
||||
|
||||
定位: 训练数据集构建工具,不是交易信号生成器。
|
||||
Feature 描述中枢内部结构,Label 记录中枢后实际演化。
|
||||
"""
|
||||
|
||||
import math
|
||||
import json
|
||||
from typing import Optional
|
||||
from ChanEnum import Chan_BI_DIR
|
||||
|
||||
|
||||
class ChanPivotClassifier:
|
||||
"""
|
||||
中枢结构特征提取 + 标签化
|
||||
输入: bi_zs_list (list[ChanBIZS])
|
||||
输出: 结构化数据集 (list[dict])
|
||||
"""
|
||||
|
||||
DATASET_VERSION = "pivot_v1"
|
||||
FEATURE_SCHEMA = ["duration_norm", "contraction", "shift_norm"]
|
||||
LABEL_SCHEMA = {"name": "break_direction", "values": ["up", "down", "none"]}
|
||||
|
||||
def __init__(self, bi_zs_list: list, symbol: str = "", timeframe: str = ""):
|
||||
self.bi_zs_list = bi_zs_list
|
||||
self.symbol = symbol
|
||||
self.timeframe = timeframe
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Feature extraction
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def calc_duration(zs) -> int:
|
||||
"""持续时间: 第一笔首K → 最后一笔末K 的 index 差"""
|
||||
bi_list = zs.bi_list
|
||||
start_idx = bi_list[0].start_klc.index
|
||||
end_idx = bi_list[-1].end_klc.index
|
||||
return end_idx - start_idx
|
||||
|
||||
@staticmethod
|
||||
def calc_contraction(zs) -> float:
|
||||
"""收敛率: 后窗口振幅均值 / 前窗口振幅均值"""
|
||||
bi_list = zs.bi_list
|
||||
if len(bi_list) < 4:
|
||||
return 1.0
|
||||
|
||||
n = min(3, len(bi_list) // 2)
|
||||
first_ranges = [bi.high - bi.low for bi in bi_list[:n]]
|
||||
last_ranges = [bi.high - bi.low for bi in bi_list[-n:]]
|
||||
|
||||
first_mean = sum(first_ranges) / len(first_ranges)
|
||||
last_mean = sum(last_ranges) / len(last_ranges)
|
||||
|
||||
if first_mean == 0:
|
||||
return 1.0
|
||||
return last_mean / first_mean
|
||||
|
||||
@staticmethod
|
||||
def calc_shift(zs) -> tuple[float, float]:
|
||||
"""重心漂移: 前后半段重心均值差 (原始值, 归一化值)"""
|
||||
bi_list = zs.bi_list
|
||||
mid = len(bi_list) // 2
|
||||
|
||||
first_centers = [(bi.high + bi.low) / 2 for bi in bi_list[:mid]]
|
||||
last_centers = [(bi.high + bi.low) / 2 for bi in bi_list[mid:]]
|
||||
|
||||
shift_raw = (
|
||||
sum(last_centers) / len(last_centers)
|
||||
- sum(first_centers) / len(first_centers)
|
||||
)
|
||||
|
||||
zs_height = zs.zg - zs.zd
|
||||
if zs_height == 0:
|
||||
shift_norm = 0.0
|
||||
else:
|
||||
shift_norm = shift_raw / zs_height
|
||||
|
||||
return shift_raw, shift_norm
|
||||
|
||||
@staticmethod
|
||||
def compute_duration_norm(duration_raw: int, historical_durations: list) -> float:
|
||||
"""用历史窗口均值归一化 duration"""
|
||||
if not historical_durations:
|
||||
return 1.0
|
||||
avg = sum(historical_durations) / len(historical_durations)
|
||||
if avg == 0:
|
||||
return 1.0
|
||||
return duration_raw / avg
|
||||
|
||||
@staticmethod
|
||||
def compute_features(zs, historical_durations: Optional[list] = None):
|
||||
"""计算单个中枢的全部结构特征(实时友好)"""
|
||||
duration_raw = ChanPivotClassifier.calc_duration(zs)
|
||||
contraction = ChanPivotClassifier.calc_contraction(zs)
|
||||
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
|
||||
|
||||
if historical_durations is not None and len(historical_durations) > 0:
|
||||
duration_norm = ChanPivotClassifier.compute_duration_norm(
|
||||
duration_raw, historical_durations
|
||||
)
|
||||
else:
|
||||
duration_norm = 1.0
|
||||
|
||||
return {
|
||||
"duration_raw": duration_raw,
|
||||
"duration_norm": round(duration_norm, 4),
|
||||
"contraction": round(contraction, 4),
|
||||
"shift_raw": round(shift_raw, 6),
|
||||
"shift_norm": round(shift_norm, 4),
|
||||
"zs_height": round(zs.zg - zs.zd, 6),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Label computation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
def _compute_label(self, zs, contraction: float, shift_norm: float) -> dict:
|
||||
"""计算标签: up / down / none + 连续置信度"""
|
||||
bi_out = zs.bi_out
|
||||
|
||||
if bi_out is None:
|
||||
return {
|
||||
"label": "none",
|
||||
"label_confidence": 0.0,
|
||||
"label_detail": {
|
||||
"bi_out_dir": "none",
|
||||
"score_breakout": 0.0,
|
||||
"score_shift": 0.0,
|
||||
"score_contraction": 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
zs_height = zs.zg - zs.zd
|
||||
if zs_height == 0:
|
||||
zs_height = 1e-8
|
||||
|
||||
# ---- 向上突破分数 ----
|
||||
if bi_out.dir == Chan_BI_DIR.UP:
|
||||
raw_breakout = (bi_out.high - zs.gg) / zs_height
|
||||
score_breakout_up = self._clamp(raw_breakout)
|
||||
score_shift_up = math.tanh(self._clamp(shift_norm, -3.0, 3.0))
|
||||
score_contraction_up = max(0.0, 1.0 - contraction)
|
||||
else:
|
||||
score_breakout_up = 0.0
|
||||
score_shift_up = 0.0
|
||||
score_contraction_up = 0.0
|
||||
|
||||
up_score = (
|
||||
score_breakout_up * 0.5
|
||||
+ score_shift_up * 0.3
|
||||
+ score_contraction_up * 0.2
|
||||
)
|
||||
|
||||
# ---- 向下突破分数 ----
|
||||
if bi_out.dir == Chan_BI_DIR.DOWN:
|
||||
raw_breakout = (zs.dd - bi_out.low) / zs_height
|
||||
score_breakout_down = self._clamp(raw_breakout)
|
||||
score_shift_down = math.tanh(self._clamp(-shift_norm, -3.0, 3.0))
|
||||
score_contraction_down = max(0.0, 1.0 - contraction)
|
||||
else:
|
||||
score_breakout_down = 0.0
|
||||
score_shift_down = 0.0
|
||||
score_contraction_down = 0.0
|
||||
|
||||
down_score = (
|
||||
score_breakout_down * 0.5
|
||||
+ score_shift_down * 0.3
|
||||
+ score_contraction_down * 0.2
|
||||
)
|
||||
|
||||
# ---- 判定 ----
|
||||
threshold = 0.15
|
||||
|
||||
if up_score > down_score and up_score > threshold:
|
||||
label = "up"
|
||||
confidence = up_score
|
||||
detail = {
|
||||
"bi_out_dir": "up",
|
||||
"score_breakout": round(score_breakout_up, 4),
|
||||
"score_shift": round(score_shift_up, 4),
|
||||
"score_contraction": round(score_contraction_up, 4),
|
||||
}
|
||||
elif down_score > up_score and down_score > threshold:
|
||||
label = "down"
|
||||
confidence = down_score
|
||||
detail = {
|
||||
"bi_out_dir": "down",
|
||||
"score_breakout": round(score_breakout_down, 4),
|
||||
"score_shift": round(score_shift_down, 4),
|
||||
"score_contraction": round(score_contraction_down, 4),
|
||||
}
|
||||
else:
|
||||
label = "none"
|
||||
confidence = max(up_score, down_score)
|
||||
bi_dir = "up" if bi_out.dir == Chan_BI_DIR.UP else "down"
|
||||
detail = {
|
||||
"bi_out_dir": bi_dir,
|
||||
"score_breakout": round(max(score_breakout_up, score_breakout_down), 4),
|
||||
"score_shift": round(max(score_shift_up, score_shift_down), 4),
|
||||
"score_contraction": round(max(score_contraction_up, score_contraction_down), 4),
|
||||
}
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"label_confidence": round(confidence, 4),
|
||||
"label_detail": detail,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def extract(self) -> list[dict]:
|
||||
"""主入口:对每个中枢提取 3 特征 + 1 标签"""
|
||||
|
||||
# 第一遍:计算原始值
|
||||
raw = []
|
||||
for i, zs in enumerate(self.bi_zs_list):
|
||||
if not zs.is_sure or len(zs.bi_list) < 3:
|
||||
continue
|
||||
|
||||
duration_raw = ChanPivotClassifier.calc_duration(zs)
|
||||
contraction = ChanPivotClassifier.calc_contraction(zs)
|
||||
shift_raw, shift_norm = ChanPivotClassifier.calc_shift(zs)
|
||||
|
||||
raw.append({
|
||||
"zs": zs,
|
||||
"zs_index": i,
|
||||
"duration_raw": duration_raw,
|
||||
"contraction": contraction,
|
||||
"shift_raw": shift_raw,
|
||||
"shift_norm": shift_norm,
|
||||
"zs_height": zs.zg - zs.zd,
|
||||
})
|
||||
|
||||
# 第二遍:组装输出 + 计算 label
|
||||
result = []
|
||||
for r in raw:
|
||||
zs = r["zs"]
|
||||
historical = [x["duration_raw"] for x in raw]
|
||||
duration_norm = ChanPivotClassifier.compute_duration_norm(
|
||||
r["duration_raw"], historical
|
||||
)
|
||||
label_info = self._compute_label(zs, r["contraction"], r["shift_norm"])
|
||||
|
||||
# 时间处理
|
||||
start_time = None
|
||||
end_time = None
|
||||
if hasattr(zs, "start_time") and zs.start_time is not None:
|
||||
start_time = str(zs.start_time)
|
||||
if hasattr(zs, "end_time") and zs.end_time is not None:
|
||||
end_time = str(zs.end_time)
|
||||
|
||||
result.append({
|
||||
"dataset_version": self.DATASET_VERSION,
|
||||
"feature_schema": self.FEATURE_SCHEMA,
|
||||
"label_schema": self.LABEL_SCHEMA,
|
||||
|
||||
"symbol": self.symbol,
|
||||
"timeframe": self.timeframe,
|
||||
"zs_index": r["zs_index"],
|
||||
"zs_start_time": start_time,
|
||||
"zs_end_time": end_time,
|
||||
|
||||
"duration_norm": round(duration_norm, 4),
|
||||
"contraction": round(r["contraction"], 4),
|
||||
"shift_norm": round(r["shift_norm"], 4),
|
||||
|
||||
"label": label_info["label"],
|
||||
"label_confidence": label_info["label_confidence"],
|
||||
"label_detail": label_info["label_detail"],
|
||||
|
||||
"duration_raw": r["duration_raw"],
|
||||
"shift_raw": round(r["shift_raw"], 6),
|
||||
"zs_height": round(r["zs_height"], 6),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def export_json(self, path: str):
|
||||
"""导出为 JSON 文件"""
|
||||
data = self.extract()
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
return len(data)
|
||||
@@ -1,145 +0,0 @@
|
||||
"""
|
||||
实时中枢特征跟踪器
|
||||
Real-time Pivot Feature Tracker
|
||||
|
||||
定位: 观察者 — 不修改管线,只观察 bi_zs_list 中当前中枢的特征变化。
|
||||
每次管线重算后调用 update(),检测 bi_count 是否增长,若增长则重新计算
|
||||
shift / contraction / duration。
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from typing import Optional
|
||||
from ChanPivotClassifier import ChanPivotClassifier
|
||||
|
||||
|
||||
class ChanPivotMonitor:
|
||||
"""
|
||||
实时追踪当前中枢的结构特征。
|
||||
|
||||
update() 每次管线重算后调用,对比 bi_count 判断是否有新笔加入中枢。
|
||||
若 bi_count 增长则重新计算 3 个结构特征并返回最新值。
|
||||
"""
|
||||
|
||||
def __init__(self, window_size: int = 10):
|
||||
self._window_size = window_size
|
||||
self._duration_history: deque[int] = deque(maxlen=window_size)
|
||||
self._current_zs_id: Optional[tuple] = None
|
||||
self._current_bi_count: int = 0
|
||||
self._current_is_sure: bool = False
|
||||
self._current_state: Optional[dict] = None
|
||||
self._duration_added_for_zs: set = set() # 已加入窗口的中枢 ID(上限 200)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self, bi_zs_list: list) -> Optional[dict]:
|
||||
"""
|
||||
主入口:检测当前中枢特征变化。
|
||||
|
||||
参数:
|
||||
bi_zs_list: 当前管线产出的笔中枢列表
|
||||
|
||||
返回:
|
||||
特征 dict(有变化时),无变化返回 None
|
||||
"""
|
||||
if not bi_zs_list:
|
||||
self._current_zs_id = None
|
||||
self._current_bi_count = 0
|
||||
self._current_is_sure = False
|
||||
self._current_state = None
|
||||
return None
|
||||
|
||||
zs = self._find_current_zs(bi_zs_list)
|
||||
if zs is None:
|
||||
return None
|
||||
|
||||
zs_id = self._make_zs_id(zs)
|
||||
bi_count = len(zs.bi_list)
|
||||
is_sure = zs.is_sure
|
||||
|
||||
# 无变化 → 跳过
|
||||
if (zs_id == self._current_zs_id
|
||||
and bi_count == self._current_bi_count
|
||||
and is_sure == self._current_is_sure):
|
||||
return None
|
||||
|
||||
# 中枢切换 → 将旧中枢 duration 加入窗口
|
||||
if zs_id != self._current_zs_id:
|
||||
self._maybe_add_to_history()
|
||||
|
||||
self._current_zs_id = zs_id
|
||||
self._current_bi_count = bi_count
|
||||
self._current_is_sure = is_sure
|
||||
|
||||
features = ChanPivotClassifier.compute_features(
|
||||
zs, list(self._duration_history)
|
||||
)
|
||||
|
||||
self._current_state = {
|
||||
"zs_id": zs_id,
|
||||
"zs_index": zs.index,
|
||||
"zs_dir": str(zs.dir),
|
||||
"bi_count": bi_count,
|
||||
"is_sure": zs.is_sure,
|
||||
"zg": round(zs.zg, 6),
|
||||
"zd": round(zs.zd, 6),
|
||||
"gg": round(zs.gg, 6),
|
||||
"dd": round(zs.dd, 6),
|
||||
**features,
|
||||
"start_time": str(t) if (t := getattr(zs, "start_time", None)) else None,
|
||||
}
|
||||
|
||||
# 中枢刚变为已确认时,将其 duration 加入滚动窗口
|
||||
if is_sure and zs_id not in self._duration_added_for_zs:
|
||||
self._add_duration(features["duration_raw"])
|
||||
self._duration_added_for_zs.add(zs_id)
|
||||
|
||||
return self._current_state
|
||||
|
||||
def get_current(self) -> Optional[dict]:
|
||||
"""返回当前中枢的最新特征"""
|
||||
return self._current_state
|
||||
|
||||
def get_duration_history(self) -> list[int]:
|
||||
"""返回用于归一化的 duration 滚动窗口"""
|
||||
return list(self._duration_history)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_zs_id(zs) -> tuple:
|
||||
"""生成中枢的稳定标识(基于首笔首K线时间戳,不随 DataFrame 窗口偏移而变化)"""
|
||||
bi0 = zs.bi_list[0]
|
||||
return (bi0.start_klc.start_time,)
|
||||
|
||||
@staticmethod
|
||||
def _find_current_zs(bi_zs_list: list):
|
||||
"""
|
||||
找到当前活跃中枢:
|
||||
优先取最后一个 is_sure=False(形成中)的中枢,
|
||||
没有则取最后一个 is_sure=True 的中枢。
|
||||
"""
|
||||
forming = None
|
||||
last_sure = None
|
||||
for zs in bi_zs_list:
|
||||
if len(zs.bi_list) < 3:
|
||||
continue
|
||||
if not zs.is_sure:
|
||||
forming = zs
|
||||
else:
|
||||
last_sure = zs
|
||||
return forming if forming is not None else last_sure
|
||||
|
||||
def _add_duration(self, duration_raw: int):
|
||||
"""将已确认中枢的 duration 加入滚动窗口"""
|
||||
self._duration_history.append(duration_raw)
|
||||
|
||||
def _maybe_add_to_history(self):
|
||||
"""旧中枢切换前,若已确认且未记录过,则将其 duration 加入窗口"""
|
||||
if (self._current_state and self._current_state["is_sure"]
|
||||
and self._current_zs_id not in self._duration_added_for_zs):
|
||||
self._add_duration(self._current_state["duration_raw"])
|
||||
self._duration_added_for_zs.add(self._current_zs_id)
|
||||
@@ -96,10 +96,7 @@ class ChanSEG():
|
||||
if self.dir == Chan_SEG_DIR.UP:
|
||||
for index in range(1, len(self.bi_list)):
|
||||
bi = self.bi_list[index]
|
||||
#print(bi.end_time, bi.next,"UP SEG BI ZS Index")
|
||||
if bi.next == None or bi.next.next == None:
|
||||
if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd):
|
||||
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
||||
continue
|
||||
bi2 = bi.next
|
||||
bi3 = bi.next.next
|
||||
@@ -121,7 +118,6 @@ class ChanSEG():
|
||||
else:
|
||||
if bi.index > last_zs.bi_list[-1].index and bi.dir == Chan_BI_DIR.DOWN and bi.is_sure:
|
||||
if bi.low > last_zs.zg or bi.high < last_zs.zd:
|
||||
#print(bi.end_time, "UP SEG BI ZS End")
|
||||
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
||||
if bi3.is_sure and bi3.index <= self.bi_list[-1].index and bi.check_bi_zs_overlap() and bi.dir == Chan_BI_DIR.DOWN:
|
||||
zg = min(bi.high, bi2.high, bi3.high)
|
||||
@@ -147,8 +143,6 @@ class ChanSEG():
|
||||
for index in range(1, len(self.bi_list)):
|
||||
bi = self.bi_list[index]
|
||||
if bi.next == None or bi.next.next == None:
|
||||
if last_zs and (bi.low > last_zs.zg or bi.high < last_zs.zd):
|
||||
last_zs.set_end_bi(last_zs.bi_list[-1], last_zs.bi_list[-1].sure_time)
|
||||
continue
|
||||
bi2 = bi.next
|
||||
bi3 = bi.next.next
|
||||
|
||||
-566
@@ -1,566 +0,0 @@
|
||||
"""
|
||||
结构价值区 (Structure Zone) 系统
|
||||
|
||||
将多时间周期的 Chan 中枢边界 (ZD/ZG/GG/DD) 和 EMA52 统一表示为带强度评分的价值区对象。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Dataclasses
|
||||
# ============================================================
|
||||
|
||||
@dataclass
|
||||
class RawZonePoint:
|
||||
"""内部中间结构:从 Chan 中枢提取的单个价格点"""
|
||||
price: float
|
||||
timeframe: str # '5m', '1h', '4h' 等
|
||||
structure_type: str # 'bi_zhongshu' | 'xd_zhongshu' | 'ema52'
|
||||
boundary_type: str # 'ZD' | 'ZG' | 'GG' | 'DD' | 'EMA52'
|
||||
source_zs_id: int # 来源 ZS 在列表中的 index(调试用)
|
||||
is_sure: bool # 来源 ZS 是否已完成
|
||||
candle_time: Optional[str] = None # 来源 ZS 的 end_time(用于 recency 计算)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructureZone:
|
||||
"""统一的价值区对象"""
|
||||
id: int
|
||||
lower: float
|
||||
upper: float
|
||||
center: float # (lower + upper) / 2
|
||||
width_pct: float # (upper - lower) / center * 100
|
||||
zone_type: str # 'support' | 'resistance' | 'neutral'
|
||||
timeframes: List[str] # 参与形成此区间的时间周期
|
||||
structure_types: List[str] # 参与形成的结构类型
|
||||
boundary_types: List[str] # 参与形成的边界类型
|
||||
overlap_count: int # 聚类中的原始点数
|
||||
touch_count: int # MVP: 等于 overlap_count
|
||||
recency_score: float # 0.0 - 1.0, 1.0 = 最近
|
||||
ema52_distance_pct: float # 到最近 EMA52 的距离百分比
|
||||
ema52_aligned: bool # 是否有 EMA52 落在区间内
|
||||
strength_score: float # 0-100 综合评分
|
||||
confidence: float # 0.0 - 1.0
|
||||
first_seen: Optional[str] # 最早的 candle_time
|
||||
last_seen: Optional[str] # 最晚的 candle_time
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StructureZoneConfig:
|
||||
"""StructureZone 提取与评分配置"""
|
||||
cluster_radius_pct: float = 0.5 # 价格聚类半径(百分比)
|
||||
min_overlap_for_zone: int = 2 # 最少重叠点数才能形成区间
|
||||
max_zones: int = 20 # 返回的最大区间数
|
||||
recency_halflife_bars: int = 50 # recency 衰减半衰期(K线数)
|
||||
zone_timeframes: List[str] = field(default_factory=lambda: ['4h', '1h', '30m', '15m', '5m'])
|
||||
kl_lines_per_tf: int = 500 # 每个时间周期使用最近多少根K线
|
||||
structure_weights: Dict[str, float] = field(default_factory=lambda: {
|
||||
'bi_zhongshu': 1.0, # 笔中枢 — 最直接的价格行为
|
||||
'xd_zhongshu': 0.8, # 线段中枢 — 较高级别但粒度较粗
|
||||
'ema52': 0.4, # EMA — 趋势参考,弱于结构
|
||||
})
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Extraction
|
||||
# ============================================================
|
||||
|
||||
def extract_raw_points_from_tf_df(
|
||||
tf_df_dict: Dict[str, Any],
|
||||
ema_symbols: List[str],
|
||||
config: StructureZoneConfig,
|
||||
) -> List[RawZonePoint]:
|
||||
"""
|
||||
从 ChanLun.tf_df_dict 中提取所有原始价格点。
|
||||
仅处理 config.zone_timeframes 中存在的时间周期。
|
||||
"""
|
||||
points: List[RawZonePoint] = []
|
||||
|
||||
for tf_name in config.zone_timeframes:
|
||||
if tf_name not in tf_df_dict:
|
||||
continue
|
||||
|
||||
tf_df = tf_df_dict[tf_name]
|
||||
|
||||
# 1. 笔中枢 (ChanBIZS)
|
||||
try:
|
||||
if hasattr(tf_df, 'seg_list') and tf_df.seg_list:
|
||||
bi_zs_result = tf_df.cal_bi_zs(tf_df.seg_list)
|
||||
if bi_zs_result:
|
||||
_extract_from_zs_objects(
|
||||
points, tf_name, 'bi_zhongshu', bi_zs_result, config.kl_lines_per_tf
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 线段中枢 (ChanZS)
|
||||
try:
|
||||
zs_list = getattr(tf_df, 'zs_list', None)
|
||||
if zs_list:
|
||||
_extract_from_zs_objects(
|
||||
points, tf_name, 'xd_zhongshu', zs_list, config.kl_lines_per_tf
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. EMA52 值
|
||||
for tf_name in config.zone_timeframes:
|
||||
if tf_name in tf_df_dict:
|
||||
try:
|
||||
ema_val = tf_df_dict[tf_name].get_ema52()
|
||||
if ema_val is not None and ema_val > 0:
|
||||
points.append(RawZonePoint(
|
||||
price=float(ema_val),
|
||||
timeframe=tf_name,
|
||||
structure_type='ema52',
|
||||
boundary_type='EMA52',
|
||||
source_zs_id=-1,
|
||||
is_sure=True,
|
||||
candle_time=None,
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return points
|
||||
|
||||
|
||||
def _extract_from_zs_objects(
|
||||
points: List[RawZonePoint],
|
||||
tf_name: str,
|
||||
structure_type: str,
|
||||
zs_list,
|
||||
kl_limit: int,
|
||||
):
|
||||
"""从 ZS 链表中提取 ZD/ZG/GG/DD 点"""
|
||||
count = 0
|
||||
node = zs_list
|
||||
while hasattr(node, 'next'):
|
||||
node = node.next
|
||||
# 从链表头开始遍历
|
||||
head = zs_list
|
||||
# 收集所有节点
|
||||
all_nodes = []
|
||||
cur = head
|
||||
while cur is not None and hasattr(cur, 'next'):
|
||||
all_nodes.append(cur)
|
||||
cur = cur.next
|
||||
# 只取最近 kl_limit 根K线内的 ZS
|
||||
all_nodes = all_nodes[-kl_limit:] if len(all_nodes) > kl_limit else all_nodes
|
||||
|
||||
for idx, zs in enumerate(all_nodes):
|
||||
if not getattr(zs, 'is_sure', False):
|
||||
continue
|
||||
try:
|
||||
zg = float(zs.zg)
|
||||
zd = float(zs.zd)
|
||||
gg = float(zs.gg) if getattr(zs, 'gg', 0) else zg
|
||||
dd = float(zs.dd) if getattr(zs, 'dd', 0) else zd
|
||||
end_time = str(zs.end_time) if hasattr(zs, 'end_time') and zs.end_time else None
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
if zg <= 0 or zd <= 0:
|
||||
continue
|
||||
|
||||
zs_id = getattr(zs, 'index', idx)
|
||||
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type=structure_type,
|
||||
boundary_type='ZG', source_zs_id=zs_id, is_sure=True,
|
||||
candle_time=end_time))
|
||||
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type=structure_type,
|
||||
boundary_type='ZD', source_zs_id=zs_id, is_sure=True,
|
||||
candle_time=end_time))
|
||||
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type=structure_type,
|
||||
boundary_type='GG', source_zs_id=zs_id, is_sure=True,
|
||||
candle_time=end_time))
|
||||
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type=structure_type,
|
||||
boundary_type='DD', source_zs_id=zs_id, is_sure=True,
|
||||
candle_time=end_time))
|
||||
|
||||
|
||||
def extract_raw_points_from_serialized(
|
||||
analyses: Dict[str, Dict],
|
||||
ema52_dict: Dict[str, Optional[float]],
|
||||
config: StructureZoneConfig,
|
||||
) -> List[RawZonePoint]:
|
||||
"""
|
||||
从已序列化的分析结果中提取价格点(用于 web API,避免重复计算)。
|
||||
analyses: {'5m': {'zs_list': [...], 'bi_zs_list': [...]}, '15m': {...}, ...}
|
||||
ema52_dict: {'5m': 123.45, '15m': None, ...}
|
||||
"""
|
||||
points: List[RawZonePoint] = []
|
||||
|
||||
for tf_name in config.zone_timeframes:
|
||||
if tf_name not in analyses:
|
||||
continue
|
||||
|
||||
analysis = analyses[tf_name]
|
||||
|
||||
# 笔中枢
|
||||
bi_zs_items = analysis.get('bi_zs_list', [])
|
||||
for idx, zs in enumerate(bi_zs_items):
|
||||
if not zs.get('is_sure', False):
|
||||
continue
|
||||
try:
|
||||
zg = float(zs['zg']); zd = float(zs['zd'])
|
||||
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
|
||||
end_time = zs.get('end_time')
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if zg <= 0 or zd <= 0:
|
||||
continue
|
||||
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||
boundary_type='ZG', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||
boundary_type='ZD', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||
boundary_type='GG', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='bi_zhongshu',
|
||||
boundary_type='DD', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
|
||||
# 线段中枢
|
||||
zs_items = analysis.get('zs_list', [])
|
||||
for idx, zs in enumerate(zs_items):
|
||||
if not zs.get('is_sure', False):
|
||||
continue
|
||||
try:
|
||||
zg = float(zs['zg']); zd = float(zs['zd'])
|
||||
gg = float(zs.get('gg', zg)); dd = float(zs.get('dd', zd))
|
||||
end_time = zs.get('end_time')
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
if zg <= 0 or zd <= 0:
|
||||
continue
|
||||
points.append(RawZonePoint(price=zg, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||
boundary_type='ZG', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
points.append(RawZonePoint(price=zd, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||
boundary_type='ZD', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
points.append(RawZonePoint(price=gg, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||
boundary_type='GG', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
points.append(RawZonePoint(price=dd, timeframe=tf_name, structure_type='xd_zhongshu',
|
||||
boundary_type='DD', source_zs_id=idx, is_sure=True,
|
||||
candle_time=str(end_time) if end_time else None))
|
||||
|
||||
# EMA52
|
||||
for tf_name in config.zone_timeframes:
|
||||
ema_val = ema52_dict.get(tf_name)
|
||||
if ema_val is not None and ema_val > 0:
|
||||
points.append(RawZonePoint(
|
||||
price=float(ema_val),
|
||||
timeframe=tf_name,
|
||||
structure_type='ema52',
|
||||
boundary_type='EMA52',
|
||||
source_zs_id=-1,
|
||||
is_sure=True,
|
||||
candle_time=None,
|
||||
))
|
||||
|
||||
return points
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Clustering
|
||||
# ============================================================
|
||||
|
||||
def cluster_raw_points(
|
||||
points: List[RawZonePoint],
|
||||
config: StructureZoneConfig,
|
||||
) -> List[List[RawZonePoint]]:
|
||||
"""
|
||||
贪心单通聚类:将价格相近的 RawZonePoint 归为一组。
|
||||
仅在 1D 价格轴上操作,O(n log n)。
|
||||
"""
|
||||
if not points:
|
||||
return []
|
||||
|
||||
sorted_points = sorted(points, key=lambda p: p.price)
|
||||
clusters: List[List[RawZonePoint]] = []
|
||||
|
||||
for p in sorted_points:
|
||||
placed = False
|
||||
for cluster in reversed(clusters):
|
||||
# 检查是否可以放入当前聚类(与聚类均价比较)
|
||||
avg_price = sum(pt.price for pt in cluster) / len(cluster)
|
||||
if abs(p.price - avg_price) / avg_price * 100 <= config.cluster_radius_pct:
|
||||
cluster.append(p)
|
||||
placed = True
|
||||
break
|
||||
if not placed:
|
||||
clusters.append([p])
|
||||
|
||||
# 过滤点数不足的聚类
|
||||
return [c for c in clusters if len(c) >= config.min_overlap_for_zone]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Scoring & Building
|
||||
# ============================================================
|
||||
|
||||
def build_structure_zones(
|
||||
clusters: List[List[RawZonePoint]],
|
||||
current_price: float,
|
||||
ema52_values: Dict[str, Optional[float]],
|
||||
latest_candle_time: Optional[str],
|
||||
config: StructureZoneConfig,
|
||||
) -> List[StructureZone]:
|
||||
"""
|
||||
从聚类构建 StructureZone 列表,计算所有字段和评分。
|
||||
"""
|
||||
zones: List[StructureZone] = []
|
||||
|
||||
# 收集所有 EMA52 值
|
||||
ema_prices = [v for v in ema52_values.values() if v is not None and v > 0]
|
||||
|
||||
for zone_id, cluster in enumerate(clusters):
|
||||
prices = [p.price for p in cluster]
|
||||
lower = min(prices)
|
||||
upper = max(prices)
|
||||
center = (lower + upper) / 2
|
||||
width_pct = (upper - lower) / center * 100 if center > 0 else 0.0
|
||||
|
||||
# 区间类型
|
||||
if upper < current_price:
|
||||
zone_type = 'support' # 区间在当前价格下方 → 支撑
|
||||
elif lower > current_price:
|
||||
zone_type = 'resistance' # 区间在当前价格上方 → 阻力
|
||||
else:
|
||||
zone_type = 'neutral' # 区间跨越当前价格
|
||||
|
||||
timeframes = sorted(set(p.timeframe for p in cluster))
|
||||
structure_types = sorted(set(p.structure_type for p in cluster))
|
||||
boundary_types = sorted(set(p.boundary_type for p in cluster))
|
||||
overlap_count = len(cluster)
|
||||
|
||||
# Recency
|
||||
times = [p.candle_time for p in cluster if p.candle_time]
|
||||
first_seen = min(times) if times else None
|
||||
last_seen = max(times) if times else None
|
||||
recency_score = _calc_recency(last_seen, latest_candle_time, config.recency_halflife_bars)
|
||||
|
||||
# EMA52 alignment
|
||||
ema52_distance_pct = 999.0
|
||||
ema52_aligned = False
|
||||
if ema_prices:
|
||||
distances = [abs(center - ep) / ep * 100 for ep in ema_prices]
|
||||
ema52_distance_pct = round(min(distances), 2)
|
||||
ema52_aligned = any(lower <= ep <= upper for ep in ema_prices)
|
||||
|
||||
# Strength score
|
||||
strength_score = _calc_strength(cluster, config, recency_score, ema52_aligned, ema52_distance_pct, width_pct)
|
||||
|
||||
# Confidence
|
||||
confidence = _calc_confidence(overlap_count, len(timeframes), cluster)
|
||||
|
||||
zones.append(StructureZone(
|
||||
id=zone_id + 1,
|
||||
lower=round(lower, 2),
|
||||
upper=round(upper, 2),
|
||||
center=round(center, 2),
|
||||
width_pct=round(width_pct, 2),
|
||||
zone_type=zone_type,
|
||||
timeframes=timeframes,
|
||||
structure_types=structure_types,
|
||||
boundary_types=boundary_types,
|
||||
overlap_count=overlap_count,
|
||||
touch_count=overlap_count, # MVP: 等于 overlap_count
|
||||
recency_score=round(recency_score, 3),
|
||||
ema52_distance_pct=ema52_distance_pct,
|
||||
ema52_aligned=ema52_aligned,
|
||||
strength_score=round(strength_score, 1),
|
||||
confidence=round(confidence, 2),
|
||||
first_seen=first_seen,
|
||||
last_seen=last_seen,
|
||||
))
|
||||
|
||||
# 按强度降序排列
|
||||
zones.sort(key=lambda z: z.strength_score, reverse=True)
|
||||
|
||||
# 截断
|
||||
if config.max_zones > 0 and len(zones) > config.max_zones:
|
||||
zones = zones[:config.max_zones]
|
||||
|
||||
return zones
|
||||
|
||||
|
||||
def _calc_recency(
|
||||
last_seen: Optional[str],
|
||||
latest_time: Optional[str],
|
||||
halflife_bars: int,
|
||||
) -> float:
|
||||
"""计算 recency 分数:越近越高"""
|
||||
if not last_seen or not latest_time:
|
||||
return 0.5
|
||||
|
||||
try:
|
||||
# 尝试解析 ISO 格式时间
|
||||
from dateutil import parser
|
||||
t_last = parser.parse(last_seen)
|
||||
t_latest = parser.parse(latest_time)
|
||||
offset_seconds = (t_latest - t_last).total_seconds()
|
||||
if offset_seconds < 0:
|
||||
return 1.0
|
||||
# 假设每根K线平均 5 分钟
|
||||
bar_seconds = 300
|
||||
offset_bars = offset_seconds / bar_seconds
|
||||
# 指数衰减: 2 ^ (-offset / halflife)
|
||||
score = 2.0 ** (-offset_bars / halflife_bars)
|
||||
return float(score)
|
||||
except Exception:
|
||||
return 0.5
|
||||
|
||||
|
||||
def _calc_strength(
|
||||
cluster: List[RawZonePoint],
|
||||
config: StructureZoneConfig,
|
||||
recency_score: float,
|
||||
ema52_aligned: bool,
|
||||
ema52_distance_pct: float,
|
||||
width_pct: float,
|
||||
) -> float:
|
||||
"""计算综合强度评分 (0-100)"""
|
||||
|
||||
# 组件 1: 结构类型多样性 (0-40)
|
||||
structure_type_counts: Dict[str, int] = {}
|
||||
for p in cluster:
|
||||
structure_type_counts[p.structure_type] = structure_type_counts.get(p.structure_type, 0) + 1
|
||||
total = sum(structure_type_counts.values())
|
||||
structure_score = 0.0
|
||||
for st, count in structure_type_counts.items():
|
||||
weight = config.structure_weights.get(st, 0.5)
|
||||
structure_score += weight * count
|
||||
structure_score = min(structure_score / max(1, total), 1.0)
|
||||
c1 = structure_score * 40
|
||||
|
||||
# 组件 2: 多周期确认 (0-25)
|
||||
tf_set = set(p.timeframe for p in cluster)
|
||||
tf_diversity = len(tf_set)
|
||||
c2 = min(tf_diversity / 5, 1.0) * 25
|
||||
|
||||
# 组件 3: 区间紧密度 (0-15) — 越窄越强
|
||||
tightness = max(0.0, 1.0 - (width_pct / 3.0))
|
||||
c3 = tightness * 15
|
||||
|
||||
# 组件 4: Recency (0-10)
|
||||
c4 = recency_score * 10
|
||||
|
||||
# 组件 5: EMA52 共振 (0-10)
|
||||
if ema52_aligned:
|
||||
ema_proximity = max(0.0, 1.0 - (ema52_distance_pct / 2.0))
|
||||
c5 = ema_proximity * 10
|
||||
else:
|
||||
c5 = 0.0
|
||||
|
||||
return c1 + c2 + c3 + c4 + c5
|
||||
|
||||
|
||||
def _calc_confidence(
|
||||
overlap_count: int,
|
||||
tf_count: int,
|
||||
cluster: List[RawZonePoint],
|
||||
) -> float:
|
||||
"""计算置信度 (0-1)"""
|
||||
base = min(overlap_count / 6.0, 0.85)
|
||||
# 多周期加分
|
||||
tf_bonus = min(tf_count / 5.0, 0.1)
|
||||
# 是否所有点都来自 sure 的 ZS
|
||||
all_sure = all(p.is_sure for p in cluster)
|
||||
sure_bonus = 0.05 if all_sure else 0.0
|
||||
return min(base + tf_bonus + sure_bonus, 1.0)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Top-level pipeline
|
||||
# ============================================================
|
||||
|
||||
def analyze_structure_zones(
|
||||
tf_df_dict: Dict[str, Any],
|
||||
ema_symbols: List[str],
|
||||
current_price: Optional[float] = None,
|
||||
config: Optional[StructureZoneConfig] = None,
|
||||
) -> List[StructureZone]:
|
||||
"""
|
||||
一站式分析:提取 → 聚类 → 评分 → 返回排序后的 StructureZone 列表。
|
||||
"""
|
||||
if config is None:
|
||||
config = StructureZoneConfig()
|
||||
|
||||
# 提取
|
||||
raw_points = extract_raw_points_from_tf_df(tf_df_dict, ema_symbols, config)
|
||||
|
||||
if not raw_points:
|
||||
return []
|
||||
|
||||
# 获取当前价格
|
||||
if current_price is None:
|
||||
for tf_name in config.zone_timeframes:
|
||||
if tf_name in tf_df_dict:
|
||||
try:
|
||||
ema_val = tf_df_dict[tf_name].get_ema52()
|
||||
if ema_val and ema_val > 0:
|
||||
current_price = float(ema_val)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if current_price is None:
|
||||
current_price = 0.0
|
||||
|
||||
# EMA52 值
|
||||
ema52_values = {}
|
||||
for tf_name in config.zone_timeframes:
|
||||
if tf_name in tf_df_dict:
|
||||
try:
|
||||
ema52_values[tf_name] = tf_df_dict[tf_name].get_ema52()
|
||||
except Exception:
|
||||
ema52_values[tf_name] = None
|
||||
|
||||
# 最晚时间
|
||||
latest_time = None
|
||||
times = [p.candle_time for p in raw_points if p.candle_time]
|
||||
if times:
|
||||
latest_time = max(times)
|
||||
|
||||
# 聚类
|
||||
clusters = cluster_raw_points(raw_points, config)
|
||||
|
||||
# 构建 & 评分
|
||||
return build_structure_zones(clusters, current_price, ema52_values, latest_time, config)
|
||||
|
||||
|
||||
def analyze_structure_zones_from_serialized(
|
||||
analyses: Dict[str, Dict],
|
||||
ema52_dict: Dict[str, Optional[float]],
|
||||
current_price: float,
|
||||
config: Optional[StructureZoneConfig] = None,
|
||||
) -> List[StructureZone]:
|
||||
"""
|
||||
从已序列化的分析结果构建 StructureZone(用于 web API)。
|
||||
"""
|
||||
if config is None:
|
||||
config = StructureZoneConfig()
|
||||
|
||||
raw_points = extract_raw_points_from_serialized(analyses, ema52_dict, config)
|
||||
|
||||
if not raw_points:
|
||||
return []
|
||||
|
||||
# 最晚时间
|
||||
latest_time = None
|
||||
times = [p.candle_time for p in raw_points if p.candle_time]
|
||||
if times:
|
||||
latest_time = max(times)
|
||||
|
||||
# EMA52 值(用于 alignment 检测)
|
||||
ema_values = {tf: v for tf, v in ema52_dict.items() if v is not None and v > 0}
|
||||
|
||||
clusters = cluster_raw_points(raw_points, config)
|
||||
return build_structure_zones(clusters, current_price, ema_values, latest_time, config)
|
||||
@@ -39,8 +39,6 @@ MACD归零轴的两种情况,两者是或的关系,满足任意一种都是
|
||||
3. MACD归零轴时,如果此时k线始终保持在EMA24附近,如果一直是EMA24之上之后出现反弹行情就会很大(最强反弹)这种反弹是2个时间级别同时归零轴形成的反弹,容易创新高新低,一般出现在强势行情。
|
||||
4. K线先触碰EMA52,而MACD黄白线都未归零轴
|
||||
|
||||
MACD归零轴反弹/反抽的完美形态是K线触碰EMA52附近,MACD的白线无限接近零轴之后出现上涨或下跌
|
||||
|
||||
高位空
|
||||
当MACD的黄白线远离零轴运行时,与零轴有一定的距离,形成了零轴的高危形态。随着K线出现缓慢上涨或者下跌,或者盘整,MACD的能量柱出现衰减,同时能量柱与MACD黄白线形成空间夹角,随着能量柱越来越小,夹角越来越大形成高位空。这种容易形成回调下跌,特别是导致次一级的MACD穿越零轴
|
||||
|
||||
@@ -57,7 +55,6 @@ MACD黄白线和零轴的几种形态:
|
||||
当MACD黄白线处于高位,随着K线出现缓慢上涨或者横盘整理,MACD黄白线保持高位出现平滑横盘走势,此时,MACD的能量柱出现衰减变化,同时能量柱和黄白线之间形成一定的空间夹脚,随着能量柱的不断衰减就导致黄白线和能量柱之间的空间夹脚越来越大,因此就形成高位空
|
||||
归零轴
|
||||
当MACD黄白线在高位,驱动K线上涨的能量所产生的加速度小于或者等于零,K线减速上涨或者下跌,能量变化越来越小,能量柱呈现出一根比一根短的排列方式
|
||||
|
||||
穿零轴
|
||||
同时满足以下两个条件
|
||||
1. 在某个时间级别,K线的价格或者指数有效击穿当前时间级别的EMA52
|
||||
|
||||
@@ -73,8 +73,8 @@ class TF_DF():
|
||||
return self.klc_list[-2]
|
||||
return None
|
||||
def add_indicators(self, df):
|
||||
fast = 26
|
||||
slow = 52
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
bb365 = ta.BBANDS(df, timeperiod=365, nbdevup=3.0, nbdevdn=3.0, matype=0)
|
||||
@@ -158,40 +158,6 @@ class TF_DF():
|
||||
print(klu_state_list[:20])
|
||||
return klu_state_list
|
||||
|
||||
def get_bsp_state(self, dataframe):
|
||||
klu_list = self.get_klu_list(dataframe)
|
||||
klc_list = self.get_klc_list(klu_list)
|
||||
bi_list = self.cal_bi_list(klc_list)
|
||||
seg_list = self.get_seg_list(bi_list)
|
||||
bi_zs_list = self.cal_bi_zs(seg_list)
|
||||
bsp_list = self.find_all_bsp(bi_list, bi_zs_list)
|
||||
bsp_state_list = [0] * len(dataframe)
|
||||
klc_index = 0
|
||||
for index in range(0, len(dataframe)):
|
||||
if klc_index == len(klc_list):
|
||||
klc_index = len(klc_list) - 1
|
||||
klc = klc_list[klc_index]
|
||||
if klc.end_klu and klc.end_klu.idx == index:
|
||||
if klc.klc_fx_type == Chan_KLC_FX.TOP2:
|
||||
bi = klc.bi.pre
|
||||
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.B3:
|
||||
# 第三类买点
|
||||
bsp_state_list[index] = -1
|
||||
#print(klc.end_time, "B3")
|
||||
else:
|
||||
bsp_state_list[index] = 0
|
||||
elif klc.klc_fx_type == Chan_KLC_FX.BOTTOM2:
|
||||
bi = klc.bi.pre
|
||||
if bi and bi.is_sure and bi.end_klc.bsp_type == Chan_BSP_TYPE.S3:
|
||||
# 第三类卖点
|
||||
bsp_state_list[index] = 1
|
||||
#print(klc.end_time, "S3")
|
||||
else:
|
||||
bsp_state_list[index] = 0
|
||||
klc_index += 1
|
||||
else:
|
||||
bsp_state_list[index] = 0
|
||||
return bsp_state_list
|
||||
def get_ema_state(self, dataframe):
|
||||
klu_list = self.get_klu_list(dataframe)
|
||||
klc_list = self.get_klc_list(klu_list)
|
||||
@@ -239,19 +205,6 @@ class TF_DF():
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
|
||||
return Chan_FX_TYPE.BOTTOM
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
def check_fx2(self, klc):
|
||||
if klc.pre and klc.next:
|
||||
if klc.high > klc.pre.close and klc.close > klc.next.close and klc.close > klc.pre.close and klc.close > klc.next.close:
|
||||
#if (klc.close > klc.ema52 or klc.next.close > klc.next.ema52) and klc.macd > 0:
|
||||
klc.set_fx(Chan_FX_TYPE.TOP)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "TOP")
|
||||
return Chan_FX_TYPE.TOP
|
||||
elif klc.low < klc.pre.close and klc.close < klc.next.close and klc.close < klc.pre.close and klc.close < klc.next.close:
|
||||
#if (klc.close < klc.ema52 or klc.next.close < klc.next.ema52) and klc.macd < 0:
|
||||
klc.set_fx(Chan_FX_TYPE.BOTTOM)
|
||||
#print(klc.start_time, klc.end_time,klc.next.start_time, klc.next.end_time, klc.macd, klc.state, klc.fx, "BOTTOM")
|
||||
return Chan_FX_TYPE.BOTTOM
|
||||
return Chan_FX_TYPE.UNKNOWN
|
||||
def check_fx_pattern(self, klc):
|
||||
klu_list = klc.pre.klu_list + klc.klu_list + klc.next.klu_list
|
||||
|
||||
@@ -1106,7 +1059,7 @@ class TF_DF():
|
||||
#klc.set_fx(Chan_FX_TYPE.PTOP)
|
||||
bi_list[-1].add_klc(klc)
|
||||
klc.set_bi(bi_list[-1])
|
||||
#print(klc.end_time, klc.fx, "无效顶分型")
|
||||
print(klc.end_time, klc.fx, "无效顶分型")
|
||||
# 满足结合律
|
||||
else:
|
||||
# New Temp TOP and last bottom confirmed ***** confirm last down bi(last bottom and last top)
|
||||
@@ -1317,7 +1270,7 @@ class TF_DF():
|
||||
if (last_top.low < klc.pre.high or last_top.low < klc.next.high) and (klc.index - last_top.index < 100):
|
||||
return False
|
||||
return True
|
||||
# 线段内的中枢
|
||||
# 建议用这种方式生成笔中枢
|
||||
def cal_bi_zs(self, seg_list):
|
||||
bi_zs_list = []
|
||||
for seg in seg_list:
|
||||
@@ -1325,7 +1278,7 @@ class TF_DF():
|
||||
if len(zs_list) > 0:
|
||||
bi_zs_list = list(bi_zs_list) + list(zs_list)
|
||||
return bi_zs_list
|
||||
# 跨段不相连的中枢
|
||||
# 这个种方式不是很好,会有很多重叠的
|
||||
def cal_bi_zs_list(self, bi_list):
|
||||
"""
|
||||
根据缠论笔中枢定义计算中枢(参照 get_zs_list 线段中枢判断规则)
|
||||
@@ -1456,282 +1409,6 @@ class TF_DF():
|
||||
if last_bi_of_zs.is_sure:
|
||||
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
|
||||
return bi_zs_list
|
||||
def get_bi_zs_list(self, bi_list):
|
||||
"""
|
||||
根据缠论笔中枢定义计算中枢(完全参照 get_seg_zs_list 线段中枢判断规则)
|
||||
从第4根笔开始(索引3),每3根笔为一组检查
|
||||
上涨中枢:后中枢 zd > 前中枢 zg(不重叠上移)
|
||||
下跌中枢:后中枢 zg < 前中枢 zd(不重叠下移)
|
||||
盘整/扩张:后中枢与前中枢整体区间有交集 → 合并扩展
|
||||
中枢可按两笔一组继续扩展到5根、7根...
|
||||
"""
|
||||
bi_zs_list = []
|
||||
if len(bi_list) < 3:
|
||||
return bi_zs_list
|
||||
|
||||
last_zs = None
|
||||
start_idx = 3
|
||||
|
||||
while start_idx < len(bi_list):
|
||||
if start_idx + 2 >= len(bi_list):
|
||||
break
|
||||
|
||||
bi1 = bi_list[start_idx]
|
||||
bi2 = bi_list[start_idx + 1]
|
||||
bi3 = bi_list[start_idx + 2]
|
||||
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg = min(bi1.high, bi2.high, bi3.high)
|
||||
zd = max(bi1.low, bi2.low, bi3.low)
|
||||
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
valid = False
|
||||
if last_zs is None:
|
||||
if bi1.dir == Chan_BI_DIR.DOWN:
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
|
||||
else:
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
|
||||
else:
|
||||
is_up_zs = zd > last_zs.zg
|
||||
is_down_zs = zg < last_zs.zd
|
||||
|
||||
if is_up_zs:
|
||||
zs_dir = Chan_ZS_DIR.UP
|
||||
valid = (bi1.dir == Chan_BI_DIR.DOWN and bi2.dir == Chan_BI_DIR.UP and bi3.dir == Chan_BI_DIR.DOWN)
|
||||
elif is_down_zs:
|
||||
zs_dir = Chan_ZS_DIR.DOWN
|
||||
valid = (bi1.dir == Chan_BI_DIR.UP and bi2.dir == Chan_BI_DIR.DOWN and bi3.dir == Chan_BI_DIR.UP)
|
||||
|
||||
create_new_zs = False
|
||||
if not valid:
|
||||
# 如果新中枢和前一个中枢的中枢区间有重叠,不形成新中枢,合并扩展
|
||||
if last_zs is not None:
|
||||
is_in_last_zs = (zd > last_zs.zd and zd < last_zs.zg) or \
|
||||
(zg < last_zs.zg and zg > last_zs.zd) or \
|
||||
(zg > last_zs.zg and zd < last_zs.zd) or \
|
||||
(zg < last_zs.zg and zd > last_zs.zd)
|
||||
if is_in_last_zs:
|
||||
# 扩展当前中枢:将 bi1-bi3 加入 last_zs
|
||||
for bi in [bi1, bi2, bi3]:
|
||||
if bi not in last_zs.bi_list:
|
||||
last_zs.add_bi(bi)
|
||||
create_new_zs = False
|
||||
else:
|
||||
start_idx += 1
|
||||
continue
|
||||
else:
|
||||
start_idx += 1
|
||||
continue
|
||||
else:
|
||||
create_new_zs = True
|
||||
|
||||
# 新中枢形成时确认前一个中枢
|
||||
if last_zs and create_new_zs:
|
||||
last_bi = last_zs.bi_list[-1]
|
||||
if last_bi and last_bi.is_sure:
|
||||
last_zs.is_sure = True
|
||||
last_zs.set_end_bi(last_bi, last_bi.sure_time)
|
||||
|
||||
zs = last_zs
|
||||
if create_new_zs:
|
||||
gg = max(bi1.high, bi2.high, bi3.high)
|
||||
dd = min(bi1.low, bi2.low, bi3.low)
|
||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
zs.set_gg(gg)
|
||||
zs.set_dd(dd)
|
||||
zs.is_sure = False
|
||||
zs.bi_list = [bi1, bi2, bi3]
|
||||
|
||||
# 离开后回抽扩展检查
|
||||
added_after_leave = []
|
||||
leave_index = start_idx + 4
|
||||
while leave_index < len(bi_list):
|
||||
b = bi_list[leave_index]
|
||||
if not b.is_sure:
|
||||
break
|
||||
if b.high >= zs.zd and b.low <= zs.zg:
|
||||
added_after_leave.append(b.pre)
|
||||
added_after_leave.append(b)
|
||||
else:
|
||||
break
|
||||
leave_index += 2
|
||||
|
||||
if added_after_leave:
|
||||
bis_for_zs = list(zs.bi_list) + list(added_after_leave)
|
||||
bi_highs = [bi.high for bi in bis_for_zs]
|
||||
bi_lows = [bi.low for bi in bis_for_zs]
|
||||
zs.set_gg(max(bi_highs))
|
||||
zs.set_dd(min(bi_lows))
|
||||
zs.bi_list = bis_for_zs
|
||||
bi = bis_for_zs[-1]
|
||||
if bi.is_sure:
|
||||
zs.set_end_bi(bi, bi.sure_time)
|
||||
start_idx = start_idx + len(added_after_leave)
|
||||
else:
|
||||
if create_new_zs:
|
||||
zs.set_end_bi(bi3, bi3.sure_time)
|
||||
|
||||
if create_new_zs:
|
||||
if last_zs:
|
||||
last_zs.set_next(zs)
|
||||
zs.set_pre(last_zs)
|
||||
bi_zs_list.append(zs)
|
||||
last_zs = zs
|
||||
|
||||
start_idx += 4
|
||||
|
||||
# 最后一个中枢:根据 bi_list 最后一笔确认状态
|
||||
if last_zs:
|
||||
last_zs.is_sure = bi_list[-1].is_sure
|
||||
|
||||
if last_zs and not last_zs.is_sure:
|
||||
if last_zs.bi_list and len(last_zs.bi_list) > 0:
|
||||
last_bi_of_zs = last_zs.bi_list[-1]
|
||||
last_bi_idx = -1
|
||||
for i, bi in enumerate(bi_list):
|
||||
if bi == last_bi_of_zs:
|
||||
last_bi_idx = i
|
||||
break
|
||||
|
||||
has_leave = False
|
||||
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
|
||||
for i in range(last_bi_idx + 1, len(bi_list)):
|
||||
bi = bi_list[i]
|
||||
if bi.is_sure:
|
||||
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
|
||||
(bi.high < last_zs.zd and bi.low < last_zs.zd)
|
||||
if leave:
|
||||
has_leave = True
|
||||
break
|
||||
|
||||
if has_leave:
|
||||
if last_bi_of_zs.is_sure:
|
||||
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
|
||||
|
||||
return bi_zs_list
|
||||
|
||||
def cal_bi_zs_list_pure(self, bi_list):
|
||||
bi_zs_list = []
|
||||
if len(bi_list) < 3:
|
||||
return bi_zs_list
|
||||
|
||||
def get_zs_range(bis):
|
||||
zg = min(bi.high for bi in bis)
|
||||
zd = max(bi.low for bi in bis)
|
||||
return zg, zd
|
||||
|
||||
def is_bi_overlap_range(bi, zg, zd):
|
||||
return bi.high >= zd and bi.low <= zg
|
||||
|
||||
def check_zs_position_filter(last_zs, zg, zd, bis):
|
||||
if last_zs is None:
|
||||
return True
|
||||
if zg <= last_zs.zd:
|
||||
return bis[0].dir == Chan_BI_DIR.UP and bis[-1].dir == Chan_BI_DIR.UP
|
||||
if zd >= last_zs.zg:
|
||||
return bis[0].dir == Chan_BI_DIR.DOWN and bis[-1].dir == Chan_BI_DIR.DOWN
|
||||
return True
|
||||
|
||||
def set_zs_bi_list(zs, bis):
|
||||
zs.bi_list = list(bis)
|
||||
for bi in zs.bi_list:
|
||||
bi.set_bi_zs(zs)
|
||||
zs.set_gg(max(bi.high for bi in zs.bi_list))
|
||||
zs.set_dd(min(bi.low for bi in zs.bi_list))
|
||||
zs.classify_zs()
|
||||
|
||||
last_zs = None
|
||||
start_idx = 0
|
||||
while start_idx + 2 < len(bi_list):
|
||||
bi1 = bi_list[start_idx]
|
||||
bi2 = bi_list[start_idx + 1]
|
||||
bi3 = bi_list[start_idx + 2]
|
||||
|
||||
if not (bi1.is_sure and bi2.is_sure and bi3.is_sure):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
if not (bi1.dir != bi2.dir and bi1.dir == bi3.dir):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zg, zd = get_zs_range([bi1, bi2, bi3])
|
||||
if zg <= zd:
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
bis_for_zs = [bi1, bi2, bi3]
|
||||
extend_idx = start_idx + 3
|
||||
while extend_idx + 1 < len(bi_list):
|
||||
leave_bi = bi_list[extend_idx]
|
||||
back_bi = bi_list[extend_idx + 1]
|
||||
if not (leave_bi.is_sure and back_bi.is_sure):
|
||||
break
|
||||
if not is_bi_overlap_range(back_bi, zg, zd):
|
||||
break
|
||||
bis_for_zs.append(leave_bi)
|
||||
bis_for_zs.append(back_bi)
|
||||
extend_idx += 2
|
||||
|
||||
if not check_zs_position_filter(last_zs, zg, zd, bis_for_zs):
|
||||
start_idx += 1
|
||||
continue
|
||||
|
||||
zs_dir = Chan_ZS_DIR.UP if bi1.dir == Chan_BI_DIR.DOWN else Chan_ZS_DIR.DOWN
|
||||
zs = ChanBIZS(bi1, len(bi_zs_list), zs_dir)
|
||||
zs.set_zg(zg)
|
||||
zs.set_zd(zd)
|
||||
|
||||
set_zs_bi_list(zs, bis_for_zs)
|
||||
zs.set_end_bi(bis_for_zs[-1], bis_for_zs[-1].sure_time)
|
||||
|
||||
if last_zs:
|
||||
last_zs.set_next(zs)
|
||||
zs.set_pre(last_zs)
|
||||
|
||||
bi_zs_list.append(zs)
|
||||
last_zs = zs
|
||||
start_idx = start_idx + len(bis_for_zs)
|
||||
|
||||
# 与 cal_bi_zs_list 一致:最后一笔未确认时末中枢标为未完成;若其后已出现确认的离开笔,仍按离开前最后一笔确认中枢结束
|
||||
if last_zs:
|
||||
last_zs.is_sure = bi_list[-1].is_sure
|
||||
|
||||
if last_zs and not last_zs.is_sure:
|
||||
if last_zs.bi_list and len(last_zs.bi_list) > 0:
|
||||
last_bi_of_zs = last_zs.bi_list[-1]
|
||||
last_bi_idx = -1
|
||||
for i, bi in enumerate(bi_list):
|
||||
if bi == last_bi_of_zs:
|
||||
last_bi_idx = i
|
||||
break
|
||||
|
||||
has_leave = False
|
||||
if last_bi_idx >= 0 and last_bi_idx + 1 < len(bi_list):
|
||||
for i in range(last_bi_idx + 1, len(bi_list)):
|
||||
bi = bi_list[i]
|
||||
if bi.is_sure:
|
||||
leave = (bi.low > last_zs.zg and bi.high > last_zs.zg) or \
|
||||
(bi.high < last_zs.zd and bi.low < last_zs.zd)
|
||||
if leave:
|
||||
has_leave = True
|
||||
break
|
||||
|
||||
if has_leave:
|
||||
if last_bi_of_zs.is_sure:
|
||||
last_zs.set_end_bi(last_bi_of_zs, last_bi_of_zs.sure_time)
|
||||
|
||||
return bi_zs_list
|
||||
def find_all_bsp(self, bi_list, bi_zs_list):
|
||||
"""
|
||||
笔中枢的三类买卖点识别
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# bsp_monitor 复用 Hermes Agent 的 Telegram bot
|
||||
# notify.py 从 ~/.hermes/.env 直接读取 TELEGRAM_BOT_TOKEN
|
||||
# 此处无需重复配置
|
||||
@@ -1 +0,0 @@
|
||||
# bsp_monitor - 缠论买卖点监控 (BTC/USDT 1m)
|
||||
@@ -1,174 +0,0 @@
|
||||
"""
|
||||
engine.py - 缠论管线封装:DataFrame → KLU → KLC → BI → SEG → ZS → BSP。
|
||||
|
||||
复用 ~/Project/Chan/ 下的 TF_DF 模块,管线步骤对齐 TF_DF.get_bsp_state()。
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
from typing import List, Optional
|
||||
|
||||
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _PARENT not in sys.path:
|
||||
sys.path.insert(0, _PARENT)
|
||||
|
||||
import pandas as pd
|
||||
from ChanEnum import (
|
||||
Chan_BSP_DIR, Chan_BSP_TYPE, Chan_KLC_FX, Chan_BI_DIR,
|
||||
Chan_ZS_DIR,
|
||||
)
|
||||
from ChanBSP import ChanBSP
|
||||
from ChanBI import ChanBI
|
||||
|
||||
# 仅导入类,不触发 TF_DF.__init__
|
||||
from TF_DF import TF_DF as _TF_DF_Class
|
||||
|
||||
|
||||
class ChanEngine:
|
||||
"""缠论管线,对齐 TF_DF.get_bsp_state() 的调用顺序。"""
|
||||
|
||||
def __init__(self, df: pd.DataFrame):
|
||||
if df.empty or len(df) < 50:
|
||||
raise ValueError("DataFrame 至少需要 50 根 K 线")
|
||||
|
||||
if "date" not in df.columns and "timestamp" in df.columns:
|
||||
df["date"] = df["timestamp"]
|
||||
|
||||
self.df = df
|
||||
self._tf = _TF_DF_Class.__new__(_TF_DF_Class) # 不调用 __init__
|
||||
|
||||
# Step 0: 添加 TA 指标 (MACD/EMA/BB/RSI)
|
||||
self._df_with_indicators = self._tf.add_indicators(df.copy())
|
||||
|
||||
# Step 1: KLU — get_klu_list → get_kl_data → cal_kl_data
|
||||
self.klu_list = self._tf.get_klu_list(self._df_with_indicators)
|
||||
|
||||
# Step 2: KLC — 内部已含 ChanMACD.cal_macd_state() + cal_trend()
|
||||
self.klc_list = self._tf.get_klc_list(self.klu_list)
|
||||
|
||||
# Step 3: BI (stroke)
|
||||
self.bi_list = self._tf.cal_bi_list(self.klc_list)
|
||||
|
||||
# Step 4: SEG (segment)
|
||||
self.seg_list = self._tf.get_seg_list(self.bi_list)
|
||||
|
||||
# Step 5: ZS — cal_bi_zs(seg_list) 对齐 get_bsp_state(从线段计算笔中枢)
|
||||
self.bi_zs_list: List = self._tf.cal_bi_zs(self.seg_list)
|
||||
|
||||
# Step 6: BSP (buy/sell points)
|
||||
self.bsp_list: List[ChanBSP] = self._tf.find_all_bsp(
|
||||
self.bi_list, self.bi_zs_list
|
||||
)
|
||||
|
||||
def get_second_last_bi(self) -> Optional[ChanBI]:
|
||||
"""获取倒数第二笔(最新确认的笔)。"""
|
||||
confirmed = [b for b in self.bi_list if b.is_sure]
|
||||
if len(confirmed) >= 2:
|
||||
return confirmed[-2]
|
||||
elif len(confirmed) == 1:
|
||||
return confirmed[-1]
|
||||
return None
|
||||
|
||||
def get_bsp_for_bi(self, bi: ChanBI) -> Optional[ChanBSP]:
|
||||
"""检查某个 Bi 的 end_klc 是否是买卖点。"""
|
||||
if bi is None or not bi.is_sure:
|
||||
return None
|
||||
klc = bi.end_klc
|
||||
if klc is None:
|
||||
return None
|
||||
if klc.bsp and klc.bsp_type != Chan_BSP_TYPE.NONE:
|
||||
for bsp in self.bsp_list:
|
||||
if bsp.klc is klc:
|
||||
return bsp
|
||||
return None
|
||||
|
||||
# ── 格式化 ──
|
||||
|
||||
@staticmethod
|
||||
def _bsp_type_name(t: Chan_BSP_TYPE) -> str:
|
||||
import ChanEnum
|
||||
names = {
|
||||
Chan_BSP_TYPE.B1: "一类买点(B1)",
|
||||
Chan_BSP_TYPE.B2: "二类买点(B2)",
|
||||
Chan_BSP_TYPE.B3: "三类买点(B3)",
|
||||
Chan_BSP_TYPE.S1: "一类卖点(S1)",
|
||||
Chan_BSP_TYPE.S2: "二类卖点(S2)",
|
||||
Chan_BSP_TYPE.S3: "三类卖点(S3)",
|
||||
}
|
||||
return names.get(t, str(t))
|
||||
|
||||
@staticmethod
|
||||
def _bi_dir_name(d) -> str:
|
||||
return "⬆️ 向上" if d == Chan_BI_DIR.UP else "⬇️ 向下"
|
||||
|
||||
@staticmethod
|
||||
def _fx_strength_name(klc_fx_type) -> str:
|
||||
import ChanEnum
|
||||
names = {
|
||||
Chan_KLC_FX.TOP0: "TOP0(弱)", Chan_KLC_FX.TOP1: "TOP1(标准)",
|
||||
Chan_KLC_FX.TOP2: "TOP2(强)", Chan_KLC_FX.TOP3: "TOP3(二类)",
|
||||
Chan_KLC_FX.TOP4: "TOP4(BB上轨)", Chan_KLC_FX.TOP5: "TOP5",
|
||||
Chan_KLC_FX.TOP6: "TOP6(高位空)", Chan_KLC_FX.TOP7: "TOP7(背驰)",
|
||||
Chan_KLC_FX.TOP8: "TOP8(信号线)",
|
||||
Chan_KLC_FX.BOTTOM0: "BOTTOM0(弱)", Chan_KLC_FX.BOTTOM1: "BOTTOM1(标准)",
|
||||
Chan_KLC_FX.BOTTOM2: "BOTTOM2(强)", Chan_KLC_FX.BOTTOM3: "BOTTOM3(二类)",
|
||||
Chan_KLC_FX.BOTTOM4: "BOTTOM4(BB下轨)", Chan_KLC_FX.BOTTOM5: "BOTTOM5(零轴下)",
|
||||
Chan_KLC_FX.BOTTOM6: "BOTTOM6(高位空)", Chan_KLC_FX.BOTTOM7: "BOTTOM7(背驰)",
|
||||
Chan_KLC_FX.BOTTOM8: "BOTTOM8(信号线)",
|
||||
}
|
||||
return names.get(klc_fx_type, f"UNKNOWN({klc_fx_type})")
|
||||
|
||||
@staticmethod
|
||||
def _utc_to_cst(time_str: str) -> str:
|
||||
"""UTC 时间字符串 → 东八区 (UTC+8)。"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
dt = datetime.fromisoformat(str(time_str))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
cst = dt.astimezone(timezone(timedelta(hours=8)))
|
||||
return cst.strftime("%Y-%m-%d %H:%M:%S CST")
|
||||
|
||||
def format_bsp_detail(self, bsp: ChanBSP, symbol: str = "BTC/USDT:USDT", tf: str = "1m") -> str:
|
||||
bi = bsp.bi
|
||||
klc = bsp.klc
|
||||
bsp_type = bsp.type
|
||||
bsp_dir = bsp.dir
|
||||
|
||||
emoji = "🟢" if bsp_dir == Chan_BSP_DIR.BUY else "🔴"
|
||||
dir_label = "买点" if bsp_dir == Chan_BSP_DIR.BUY else "卖点"
|
||||
|
||||
symbol_short = symbol.split(":")[0].replace("/", "")
|
||||
lines = [
|
||||
f"{emoji} [{dir_label}] {self._bsp_type_name(bsp_type)} — <b>{symbol_short} {tf}</b>",
|
||||
"",
|
||||
f"⏰ 确认: <code>{self._utc_to_cst(klc.end_time)}</code>",
|
||||
f"💰 价格: <b>{klc.close:.2f}</b>",
|
||||
f"📐 笔方向: {self._bi_dir_name(bi.dir)}",
|
||||
f"📏 笔高度: ${bi.height:.2f} 宽度: {bi.width}K 斜率: {bi.slop:.2f}",
|
||||
f"🔩 分型强度: {self._fx_strength_name(klc.klc_fx_type)}",
|
||||
]
|
||||
|
||||
if bsp.zs:
|
||||
zs = bsp.zs
|
||||
zs_dir = "UP" if hasattr(zs, 'dir') and hasattr(Chan_ZS_DIR, 'UP') and zs.dir == Chan_ZS_DIR.UP else "DOWN"
|
||||
lines.append(f"🏠 中枢: {zs.zd:.2f} – {zs.zg:.2f} ({zs_dir}, #{getattr(zs, 'index', 0) + 1})")
|
||||
|
||||
if bsp.type in (Chan_BSP_TYPE.B1, Chan_BSP_TYPE.S1):
|
||||
lines.append("📊 MACD背驰: 有 (离开段能量 < 进入段)")
|
||||
|
||||
if hasattr(klc, 'ema_status') and klc.ema_status:
|
||||
ema52 = klc.ema_status.get('ema52', {})
|
||||
if ema52:
|
||||
pos = str(ema52.get('pos', '?'))
|
||||
lines.append(f"📈 EMA52: {pos} (值: {klc.ema52:.2f})")
|
||||
|
||||
lines.append(f"📋 KLC状态: {klc.klc_state}")
|
||||
|
||||
if bi.pre:
|
||||
prev = bi.pre
|
||||
lines.extend([
|
||||
"────",
|
||||
f"⬅️ 前一笔: {self._bi_dir_name(prev.dir)} "
|
||||
f"高度: ${prev.height:.2f} 宽度: {prev.width}K",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""
|
||||
fetcher.py - 从 data_provider HTTP API 拉取 K 线数据。
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import requests
|
||||
import pandas as pd
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROVIDER_URL = "http://103.179.242.166"
|
||||
PROVIDER_URL = "http://127.0.0.1"
|
||||
FETCH_LIMIT = 1000
|
||||
|
||||
_symbols_cache: Optional[List[str]] = None
|
||||
|
||||
|
||||
# 只推送 BTC,其他币对暂不监控
|
||||
_SYMBOL_WHITELIST = {"BTC/USDT:USDT", "ETH/USDT:USDT", "SOL/USDT:USDT"}
|
||||
|
||||
|
||||
def get_symbols() -> list[str]:
|
||||
"""获取要监控的币对列表(目前只监控 BTC)。"""
|
||||
global _symbols_cache
|
||||
if _symbols_cache is not None:
|
||||
return _symbols_cache
|
||||
try:
|
||||
resp = requests.get(f"{PROVIDER_URL}/health", timeout=10)
|
||||
resp.raise_for_status()
|
||||
all_symbols = resp.json().get("symbols", [])
|
||||
_symbols_cache = [s for s in all_symbols if s in _SYMBOL_WHITELIST]
|
||||
logger.info(f"获取到 {len(all_symbols)} 个币对,过滤后监控 {len(_symbols_cache)} 个: {_symbols_cache}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取币对列表失败: {e}")
|
||||
_symbols_cache = ["BTC/USDT:USDT"]
|
||||
return _symbols_cache
|
||||
|
||||
|
||||
def fetch_ohlcv(symbol: str, tf: str = "1m") -> pd.DataFrame:
|
||||
"""从 data_provider API 拉取某个币对最近 FETCH_LIMIT 根 K 线。"""
|
||||
url = f"{PROVIDER_URL}/api/candles"
|
||||
params = {
|
||||
"symbol": symbol,
|
||||
"tf": tf,
|
||||
"limit": FETCH_LIMIT,
|
||||
}
|
||||
resp = requests.get(url, params=params, timeout=30)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
if not data:
|
||||
logger.warning(f"{symbol}: API 返回空数据")
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
|
||||
df["date"] = df["timestamp"]
|
||||
|
||||
df = df.drop_duplicates(subset="timestamp").sort_values("timestamp").reset_index(drop=True)
|
||||
return df
|
||||
@@ -1,220 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
main.py - 缠论多周期买卖点监控。
|
||||
|
||||
每整分钟:
|
||||
1. 从 data_provider 拉取所有币对多周期 K 线
|
||||
2. 每个币对 × 每个周期独立跑缠论管线
|
||||
3. 检测新笔确认 → BSP 推送
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from fetcher import fetch_ohlcv, get_symbols
|
||||
from engine import ChanEngine
|
||||
from notify import send_bsp_alert, BOT_TOKEN, CHAT_ID
|
||||
|
||||
_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _PARENT not in sys.path:
|
||||
sys.path.insert(0, _PARENT)
|
||||
from ChanEnum import Chan_BI_DIR
|
||||
# from ChanPivotMonitor import ChanPivotMonitor # 暂停中枢监控
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("bsp_monitor")
|
||||
|
||||
TIMEFRAMES = ["1m", "5m", "15m", "1h"]
|
||||
|
||||
|
||||
def _short(symbol: str) -> str:
|
||||
"""BTC/USDT:USDT → BTCUSDT"""
|
||||
return symbol.split(":")[0].replace("/", "")
|
||||
|
||||
|
||||
def _bi_id(bi) -> Optional[tuple]:
|
||||
"""笔的稳定标识,基于首K线时间戳。"""
|
||||
if bi.start_klc is None:
|
||||
return None
|
||||
return (bi.start_klc.start_time,)
|
||||
|
||||
|
||||
def _push_bsp(engine: ChanEngine, bsp, symbol: str, tf: str) -> bool:
|
||||
"""推送 BSP 到 Telegram(带去重)。"""
|
||||
if bsp.klc is None:
|
||||
return False
|
||||
key = f"{symbol}_{bsp.type}_{bsp.klc.end_time}_{tf}"
|
||||
msg = engine.format_bsp_detail(bsp, symbol, tf)
|
||||
msg = _escape_html(msg)
|
||||
if send_bsp_alert(msg, bsp_key=key):
|
||||
logger.info(f"[{_short(symbol)} {tf}] ✅ BSP: {key}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TfState:
|
||||
"""单个周期的状态。"""
|
||||
last_bi_id: Optional[tuple] = None
|
||||
last_df_ts: object = None
|
||||
first_run: bool = True
|
||||
# pivot_monitor: ChanPivotMonitor = None # 暂停中枢监控
|
||||
|
||||
# def __post_init__(self):
|
||||
# if self.pivot_monitor is None:
|
||||
# self.pivot_monitor = ChanPivotMonitor()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SymbolState:
|
||||
symbol: str
|
||||
tfs: dict = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
self.tfs = {tf: TfState() for tf in TIMEFRAMES}
|
||||
|
||||
|
||||
class BSPMonitor:
|
||||
def __init__(self):
|
||||
symbols = get_symbols()
|
||||
self._states: dict[str, SymbolState] = {
|
||||
s: SymbolState(symbol=s) for s in symbols
|
||||
}
|
||||
logger.info(f"监控 {len(symbols)}×{len(TIMEFRAMES)} 币对×周期: "
|
||||
f"{', '.join(_short(s) for s in symbols)}")
|
||||
|
||||
async def tick(self):
|
||||
tick_start = time.monotonic()
|
||||
logger.info("── tick 开始 ──")
|
||||
|
||||
for symbol, st in self._states.items():
|
||||
await self._tick_symbol(symbol, st)
|
||||
|
||||
elapsed = (time.monotonic() - tick_start) * 1000
|
||||
logger.info(f"── tick 结束 ({elapsed:.0f}ms) ──")
|
||||
|
||||
async def _tick_symbol(self, symbol: str, st: SymbolState):
|
||||
name = _short(symbol)
|
||||
|
||||
for tf in TIMEFRAMES:
|
||||
await self._check_tf(symbol, tf, st.tfs[tf], name)
|
||||
|
||||
async def _check_tf(self, symbol: str, tf: str, ts: TfState, name: str):
|
||||
# 1. 拉取 K 线
|
||||
try:
|
||||
df = fetch_ohlcv(symbol, tf)
|
||||
except Exception as e:
|
||||
logger.error(f"[{name} {tf}] 拉取失败: {e}")
|
||||
return
|
||||
|
||||
if df.empty:
|
||||
return
|
||||
|
||||
# 2. 检查是否有新 K 线
|
||||
latest_ts = df.iloc[-1]["timestamp"]
|
||||
if ts.last_df_ts and latest_ts <= ts.last_df_ts:
|
||||
return
|
||||
ts.last_df_ts = latest_ts
|
||||
|
||||
# 3. 运行缠论管线
|
||||
try:
|
||||
engine = ChanEngine(df)
|
||||
except Exception as e:
|
||||
logger.error(f"[{name} {tf}] 缠论计算失败: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
# 4. 中枢特征更新(暂停)
|
||||
# try:
|
||||
# ts.pivot_monitor.update(engine.bi_zs_list)
|
||||
# except Exception as e:
|
||||
# logger.debug(f"[{name} {tf}] 中枢特征更新失败: {e}")
|
||||
|
||||
# 5. BSP 检测
|
||||
confirmed = [b for b in engine.bi_list if b.is_sure]
|
||||
if len(confirmed) < 2:
|
||||
return
|
||||
|
||||
last_confirmed = confirmed[-1]
|
||||
current_bi_id = _bi_id(last_confirmed)
|
||||
if current_bi_id is None:
|
||||
return
|
||||
|
||||
if ts.first_run:
|
||||
ts.first_run = False
|
||||
ts.last_bi_id = current_bi_id
|
||||
|
||||
bsp = engine.get_bsp_for_bi(last_confirmed)
|
||||
if bsp:
|
||||
_push_bsp(engine, bsp, symbol, tf)
|
||||
|
||||
logger.info(
|
||||
f"[{name} {tf}] 首次完成 — "
|
||||
f"{len(confirmed)} 笔, {len(engine.bsp_list)} BSP"
|
||||
)
|
||||
return
|
||||
|
||||
if current_bi_id == ts.last_bi_id:
|
||||
return
|
||||
|
||||
ts.last_bi_id = current_bi_id
|
||||
|
||||
bi_dir = "⬆️" if last_confirmed.dir == Chan_BI_DIR.UP else "⬇️"
|
||||
logger.info(f"[{name} {tf}] 新笔确认 — #{len(confirmed)} "
|
||||
f"{bi_dir} 高度: ${last_confirmed.height:.2f}")
|
||||
|
||||
bsp = engine.get_bsp_for_bi(last_confirmed)
|
||||
if bsp:
|
||||
_push_bsp(engine, bsp, symbol, tf)
|
||||
|
||||
async def run(self):
|
||||
logger.info("=" * 50)
|
||||
logger.info(f"bsp_monitor 启动 — {len(self._states)} 币对 "
|
||||
f"× {len(TIMEFRAMES)} 周期 ({', '.join(TIMEFRAMES)})")
|
||||
logger.info(f"Telegram: {'已配置' if BOT_TOKEN and CHAT_ID else '⚠️ 未配置'}")
|
||||
logger.info("=" * 50)
|
||||
|
||||
logger.info("首次运行(初始化)...")
|
||||
await self.tick()
|
||||
|
||||
while True:
|
||||
now = datetime.now(timezone.utc)
|
||||
next_minute = now.replace(second=0, microsecond=0) + timedelta(minutes=1)
|
||||
wait_seconds = max(0.1, (next_minute - now).total_seconds())
|
||||
|
||||
logger.info(f"等待 {wait_seconds:.0f}s 到 {next_minute.strftime('%H:%M:%S')}UTC")
|
||||
await asyncio.sleep(wait_seconds)
|
||||
|
||||
try:
|
||||
await self.tick()
|
||||
except Exception as e:
|
||||
logger.error(f"tick 异常: {e}", exc_info=True)
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
def _escape_html(msg: str) -> str:
|
||||
"""HTML 转义,保留已有的 <b>/<code> 标签。"""
|
||||
msg = msg.replace("&", "&")
|
||||
msg = msg.replace("<b>", "\x00B\x00").replace("</b>", "\x00/B\x00")
|
||||
msg = msg.replace("<code>", "\x00C\x00").replace("</code>", "\x00/C\x00")
|
||||
msg = msg.replace("<", "<").replace(">", ">")
|
||||
msg = msg.replace("\x00B\x00", "<b>").replace("\x00/B\x00", "</b>")
|
||||
msg = msg.replace("\x00C\x00", "<code>").replace("\x00/C\x00", "</code>")
|
||||
return msg
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
monitor = BSPMonitor()
|
||||
try:
|
||||
asyncio.run(monitor.run())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到中断信号,退出")
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
notify.py - Telegram 推送。
|
||||
"""
|
||||
import logging
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BOT_TOKEN = "8742822093:AAGzD1vS7ru7ROhgcOjA-UyHb4R8Cfcqv3Q"
|
||||
CHAT_ID = "580807463"
|
||||
|
||||
|
||||
def send_telegram_message(text: str) -> bool:
|
||||
"""发送 Telegram 消息(不去重,每次调用都发)。"""
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
logger.warning("Telegram 未配置,跳过推送")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={
|
||||
"chat_id": CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram 推送失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def send_bsp_alert(text: str, bsp_key: str = "") -> bool:
|
||||
"""推送 BSP 消息。"""
|
||||
if not BOT_TOKEN or not CHAT_ID:
|
||||
logger.warning("Telegram 未配置,跳过推送")
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
try:
|
||||
resp = requests.post(
|
||||
url,
|
||||
json={
|
||||
"chat_id": CHAT_ID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"Telegram 推送成功: {bsp_key or 'no-key'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram 推送失败: {e}")
|
||||
return False
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
# bsp_monitor 启动脚本
|
||||
# 用法: bash run.sh
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
echo "=== bsp_monitor ==="
|
||||
echo "启动时间: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "监控: BTC/USDT:USDT 1m 缠论买卖点"
|
||||
echo "推送: Telegram (复用 Hermes bot)"
|
||||
echo "==================="
|
||||
exec /usr/bin/python3 -u main.py
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.freqtrade.io/schema.json",
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.chanlun_btc_1m.sqlite",
|
||||
"dry_run_wallet": 1000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short" : true,
|
||||
"timeframe" : "1m",
|
||||
"process_only_new_candles" : false,
|
||||
"unfilledtimeout": {
|
||||
"entry": 1,
|
||||
"exit": 1,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList",
|
||||
"number_assets": 1,
|
||||
"sort_key": "quoteVolume",
|
||||
"min_value": 0,
|
||||
"refresh_period": 1800
|
||||
}
|
||||
],
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "8197349375:AAH208JghCq8raFYF-IpnobYknCr6iGDH_0",
|
||||
"chat_id": "580807463"
|
||||
},
|
||||
"api_server": {
|
||||
"enabled": true,
|
||||
"listen_ip_address": "0.0.0.0",
|
||||
"listen_port": 8814,
|
||||
"verbosity": "error",
|
||||
"enable_openapi": false,
|
||||
"jwt_secret_key": "14d3510740e2c39a973a8895f1aa2704d98d08b86170260085709fa5ea48251d",
|
||||
"ws_token": "dtKKDnafBrX4icq_ZCw7acJTahTK4h_yvg",
|
||||
"CORS_origins": [],
|
||||
"username": "freqtrader",
|
||||
"password": "FreqTrade007"
|
||||
},
|
||||
"bot_name": "freqtrade",
|
||||
"initial_state": "running",
|
||||
"force_entry_enable": false,
|
||||
"internals": {
|
||||
"process_throttle_secs": 1
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"max_open_trades": 1,
|
||||
"stake_currency": "USDT",
|
||||
"stake_amount": "unlimited",
|
||||
"tradable_balance_ratio": 0.99,
|
||||
"fiat_display_currency": "USD",
|
||||
"dry_run": true,
|
||||
"db_url": "sqlite:///tradesv3.chanlun_btc_5m.sqlite",
|
||||
"dry_run_wallet": 1000,
|
||||
"cancel_open_orders_on_exit": true,
|
||||
"trading_mode": "futures",
|
||||
"margin_mode": "isolated",
|
||||
"can_short" : true,
|
||||
"timeframe" : "5m",
|
||||
"process_only_new_candles" : false,
|
||||
"unfilledtimeout": {
|
||||
"entry": 5,
|
||||
"exit": 5,
|
||||
"exit_timeout_count": 5,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"order_types": {
|
||||
"entry": "limit",
|
||||
"exit": "limit",
|
||||
"stoploss": "limit",
|
||||
"stoploss_on_exchange": false
|
||||
},
|
||||
"entry_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exit_pricing": {
|
||||
"price_side": "same",
|
||||
"use_order_book": true
|
||||
},
|
||||
"exchange": {
|
||||
"name": "binance",
|
||||
"key": "hvoXanRExQvcN4tyGFvEnsSF4gqxXp6ZJnBu5lnhvlVuHaDbj2PhLBQGCLkkyeI8",
|
||||
"secret": "3UKA2oyDj7OoXrausmnaLwLlNfXmlNf2imBdmQqqKHArcJfk6X9xjaUF19wzu82l",
|
||||
"ccxt_config": {
|
||||
"proxies": {
|
||||
"http": "http://127.0.0.1:7897",
|
||||
"https": "http://127.0.0.1:7897"
|
||||
}
|
||||
},
|
||||
"ccxt_async_config": {
|
||||
"aiohttp_proxy": "http://127.0.0.1:7897"
|
||||
},
|
||||
"pair_whitelist": [
|
||||
"BTC/USDT:USDT"
|
||||
],
|
||||
"pair_blacklist": [
|
||||
"BNB/.*"
|
||||
]
|
||||
},
|
||||
"pairlists": [
|
||||
{
|
||||
"method": "StaticPairList",
|
||||
"number_assets": 1,
|
||||
"sort_key": "quoteVolume",
|
||||
"min_value": 0,
|
||||
"refresh_period": 1800
|
||||
}
|
||||
],
|
||||
"internals": {
|
||||
"process_throttle_secs": 5
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -12,9 +12,9 @@ COPY . /app
|
||||
|
||||
ENV CONFIG_PATH=/app/config.json \
|
||||
UVICORN_HOST=0.0.0.0 \
|
||||
UVICORN_PORT=80
|
||||
UVICORN_PORT=9009
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 9009
|
||||
|
||||
CMD ["python", "-m", "main"]
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Chan 数据提供商 (Chan Data Provider)
|
||||
|
||||
从 **Binance 期货** 交易所拉取加密货币 K 线数据,提供 HTTP + WebSocket 数据服务。
|
||||
|
||||
## 功能
|
||||
|
||||
- **多交易对**:支持 BTC, ETH, SOL, DOGE 等 9 个交易对
|
||||
- **多时间周期**:基础周期 1m/1h/1d/1w,可合成 30+ 种衍生周期(如 5m, 15m, 4h 等)
|
||||
- **本地缓存**:CSV 持久化到磁盘,重启快速加载
|
||||
- **断线恢复**:交易所连接中断时记录断点,自动补拉缺失数据
|
||||
- **实时推送**:WebSocket 订阅最新 K 线更新
|
||||
- **内存服务**:启动即加载本地数据,不阻塞服务
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker compose up -d
|
||||
|
||||
# 直接运行
|
||||
python main.py
|
||||
|
||||
# 或指定配置
|
||||
CONFIG_PATH=./config.json python main.py
|
||||
```
|
||||
|
||||
服务默认监听 `http://0.0.0.0:9009`。
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"exchange": "binance",
|
||||
"symbols": ["BTC/USDT:USDT", "ETH/USDT:USDT"],
|
||||
"start_time": "2024-01-01T00:00:00Z",
|
||||
"timeframes": ["1m", "1h", "1d", "1w"],
|
||||
"data_dir": "./data"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `exchange` | 交易所名称(ccxt 支持即可) |
|
||||
| `symbols` | 交易对列表 |
|
||||
| `start_time` | 历史数据起始时间 |
|
||||
| `timeframes` | 基础周期(从交易所直接拉取) |
|
||||
| `data_dir` | CSV 数据存储目录 |
|
||||
|
||||
## 可用周期
|
||||
|
||||
### 基础周期(交易所直接拉取)
|
||||
`1m`, `1h`, `1d`, `1w`
|
||||
|
||||
### 衍生周期(内存中合成)
|
||||
| 基础周期 | 可合成的衍生周期 |
|
||||
|----------|----------------|
|
||||
| 1m | 2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m, 45m |
|
||||
| 1h | 2h, 3h, 4h, 5h, 6h, 7h, 8h, 9h, 10h, 11h, 12h, 16h, 20h |
|
||||
| 1d | 2d, 3d, 4d, 5d, 6d |
|
||||
| 1w | 2w, 3w |
|
||||
|
||||
## 数据存储
|
||||
|
||||
数据以 CSV 格式存储,按时间周期分目录:
|
||||
|
||||
```
|
||||
./data/
|
||||
1m/
|
||||
binance_BTC_USDT_USDT_1m.csv
|
||||
binance_ETH_USDT_USDT_1m.csv
|
||||
...
|
||||
1h/
|
||||
...
|
||||
```
|
||||
|
||||
每根 K 线包含:`timestamp`, `datetime`, `open`, `high`, `low`, `close`, `volume`。
|
||||
|
||||
---
|
||||
|
||||
API 文档请访问 `http://<host>:9009/api/docs`。
|
||||
@@ -1,568 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chan 数据提供商 - API 文档</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--orange: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--mono: "SF Mono", "Fira Code", "Consolas", monospace;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
padding: 0;
|
||||
}
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 24px 20px; }
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 32px 0 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
header h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; }
|
||||
header h1 span { color: var(--accent); }
|
||||
header .subtitle { color: var(--text-secondary); font-size: 15px; }
|
||||
header .badge {
|
||||
display: inline-block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 10px;
|
||||
font-size: 13px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 12px;
|
||||
}
|
||||
header .badge span { color: var(--green); }
|
||||
|
||||
/* Section */
|
||||
section { margin-bottom: 40px; }
|
||||
section h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 20px 0 8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Endpoint card */
|
||||
.endpoint {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.endpoint-header:hover { background: rgba(255,255,255,0.03); }
|
||||
.method {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
}
|
||||
.method.get { background: #1c3d5a; color: var(--accent); }
|
||||
.method.ws { background: #2d1b5e; color: var(--purple); }
|
||||
.endpoint-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.endpoint-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
.endpoint-body {
|
||||
padding: 0 16px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: none;
|
||||
}
|
||||
.endpoint.open .endpoint-body { display: block; }
|
||||
.endpoint-body > div { margin-top: 12px; }
|
||||
|
||||
/* Table */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th { color: var(--text-secondary); font-weight: 500; font-size: 12px; text-transform: uppercase; }
|
||||
td { font-family: var(--mono); font-size: 13px; }
|
||||
td.optional { color: var(--text-secondary); font-size: 12px; }
|
||||
td .type { color: var(--orange); }
|
||||
td .type-num { color: var(--accent); }
|
||||
|
||||
/* Code block */
|
||||
pre {
|
||||
background: #010409;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 8px 0;
|
||||
}
|
||||
code { font-family: var(--mono); font-size: 13px; }
|
||||
pre .comment { color: #8b949e; }
|
||||
pre .string { color: #a5d6ff; }
|
||||
pre .key { color: #79c0ff; }
|
||||
pre .num { color: #79c0ff; }
|
||||
pre .null { color: #d2a8ff; }
|
||||
pre .bool { color: #d2a8ff; }
|
||||
|
||||
/* WS message box */
|
||||
.ws-box {
|
||||
background: #010409;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ws-box .label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
p { font-size: 14px; color: var(--text-secondary); margin-bottom: 8px; }
|
||||
ul { padding-left: 20px; font-size: 14px; color: var(--text-secondary); }
|
||||
li { margin-bottom: 4px; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.note {
|
||||
background: rgba(210, 153, 34, 0.1);
|
||||
border: 1px solid rgba(210, 153, 34, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--orange);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.toc { margin-bottom: 32px; }
|
||||
.toc a {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
margin: 2px 0;
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<h1><span>Chan</span> 数据提供商</h1>
|
||||
<p class="subtitle">加密货币 K 线 + 衍生品数据 HTTP + WebSocket API</p>
|
||||
<div class="badge">v1.0.0 | <span>binance</span> | port 9009</div>
|
||||
</header>
|
||||
|
||||
<nav class="toc">
|
||||
<a href="#root">GET /</a>
|
||||
<a href="#health">GET /health</a>
|
||||
<a href="#timeframes">GET /timeframes</a>
|
||||
<a href="#candles">GET /api/candles</a>
|
||||
<a href="#derivatives">GET /api/derivatives</a>
|
||||
<a href="#websocket">WebSocket /ws</a>
|
||||
<a href="#timeframes-ref">时间周期参考</a>
|
||||
</nav>
|
||||
|
||||
<!-- ============ GET / ============ -->
|
||||
<section id="root">
|
||||
<h2>服务信息</h2>
|
||||
<div class="endpoint open">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="method get">GET</span>
|
||||
<span class="endpoint-path">/</span>
|
||||
<span class="endpoint-desc">服务基本信息</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
<p>返回服务名称、交易所、交易对列表、可用周期及就绪状态。</p>
|
||||
<h3>响应</h3>
|
||||
<pre>{
|
||||
<span class="key">"service"</span>: <span class="string">"Data Provider"</span>,
|
||||
<span class="key">"exchange"</span>: <span class="string">"binance"</span>,
|
||||
<span class="key">"symbols"</span>: [<span class="string">"BTC/USDT:USDT"</span>, <span class="string">"ETH/USDT:USDT"</span>, ...],
|
||||
<span class="key">"base_timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, <span class="string">"1d"</span>, <span class="string">"1w"</span>],
|
||||
<span class="key">"derived_timeframes"</span>: [<span class="string">"5m"</span>, <span class="string">"15m"</span>, <span class="string">"4h"</span>, ...],
|
||||
<span class="key">"timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, ..., <span class="string">"5m"</span>, <span class="string">"15m"</span>, ...],
|
||||
<span class="key">"ready"</span>: <span class="bool">true</span>
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ GET /health ============ -->
|
||||
<section id="health">
|
||||
<h2>健康检查</h2>
|
||||
<div class="endpoint open">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="method get">GET</span>
|
||||
<span class="endpoint-path">/health</span>
|
||||
<span class="endpoint-desc">存活检查</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
<p>返回服务健康状态,与 <code>/</code> 相同结构,适合负载均衡探测器。</p>
|
||||
<h3>响应</h3>
|
||||
<pre>{
|
||||
<span class="key">"status"</span>: <span class="string">"ok"</span>,
|
||||
<span class="key">"exchange"</span>: <span class="string">"binance"</span>,
|
||||
<span class="key">"symbols"</span>: [<span class="string">"BTC/USDT:USDT"</span>, ...],
|
||||
<span class="key">"ready"</span>: <span class="bool">true</span>,
|
||||
...
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ GET /timeframes ============ -->
|
||||
<section id="timeframes">
|
||||
<h2>可用周期</h2>
|
||||
<div class="endpoint open">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="method get">GET</span>
|
||||
<span class="endpoint-path">/timeframes</span>
|
||||
<span class="endpoint-desc">列出所有时间周期</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
<p>返回基础周期(交易所直接拉取)和衍生周期(合成生成)的完整列表。</p>
|
||||
<h3>响应</h3>
|
||||
<pre>{
|
||||
<span class="key">"base_timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, <span class="string">"1d"</span>, <span class="string">"1w"</span>],
|
||||
<span class="key">"derived_timeframes"</span>: [<span class="string">"5m"</span>, <span class="string">"15m"</span>, <span class="string">"4h"</span>, ...],
|
||||
<span class="key">"timeframes"</span>: [<span class="string">"1m"</span>, <span class="string">"1h"</span>, ..., <span class="string">"5m"</span>, <span class="string">"15m"</span>, ...]
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ GET /api/candles ============ -->
|
||||
<section id="candles">
|
||||
<h2>查询 K 线</h2>
|
||||
<div class="endpoint open">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="method get">GET</span>
|
||||
<span class="endpoint-path">/api/candles</span>
|
||||
<span class="endpoint-desc">获取 OHLCV K 线数据</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
|
||||
<table>
|
||||
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
||||
<tr>
|
||||
<td>symbol</td>
|
||||
<td><span class="type">string</span></td>
|
||||
<td>是</td>
|
||||
<td>交易对,如 <code>BTC/USDT:USDT</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>tf</td>
|
||||
<td><span class="type">string</span></td>
|
||||
<td>否</td>
|
||||
<td>时间周期,默认 <code>1m</code>。支持基础及衍生周期</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>start</td>
|
||||
<td><span class="type-num">int</span></td>
|
||||
<td class="optional">可选</td>
|
||||
<td>开始时间戳(毫秒)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>end</td>
|
||||
<td><span class="type-num">int</span></td>
|
||||
<td class="optional">可选</td>
|
||||
<td>结束时间戳(毫秒)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>limit</td>
|
||||
<td><span class="type-num">int</span></td>
|
||||
<td class="optional">可选</td>
|
||||
<td>限制返回的 K 线数量(返回最后 N 根)</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="note">若不传 start/end,返回内存中全部数据(可能很多),建议搭配 limit 使用。</div>
|
||||
|
||||
<h3>请求示例</h3>
|
||||
<pre><span class="comment"># 获取 BTC 最近 100 根 5 分钟 K 线</span>
|
||||
GET /api/candles?symbol=BTC/USDT:USDT&tf=5m&limit=100
|
||||
|
||||
<span class="comment"># 指定时间范围</span>
|
||||
GET /api/candles?symbol=ETH/USDT:USDT&tf=1h&start=1704067200000&end=1704153600000
|
||||
|
||||
<span class="comment"># 获取 4 小时周期(衍生周期)</span>
|
||||
GET /api/candles?symbol=SOL/USDT:USDT&tf=4h&limit=50</pre>
|
||||
|
||||
<h3>响应</h3>
|
||||
<p>返回 OHLCV 对象数组:</p>
|
||||
<pre>[
|
||||
{
|
||||
<span class="key">"timestamp"</span>: <span class="num">1704067200000</span>,
|
||||
<span class="key">"datetime"</span>: <span class="string">"2024-01-01T00:00:00Z"</span>,
|
||||
<span class="key">"open"</span>: <span class="num">42850.12</span>,
|
||||
<span class="key">"high"</span>: <span class="num">43100.00</span>,
|
||||
<span class="key">"low"</span>: <span class="num">42780.50</span>,
|
||||
<span class="key">"close"</span>: <span class="num">43050.80</span>,
|
||||
<span class="key">"volume"</span>: <span class="num">125.34</span>
|
||||
},
|
||||
...
|
||||
]</pre>
|
||||
|
||||
<h3>字段说明</h3>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td>timestamp</td><td><span class="type-num">int</span></td><td>UTC 毫秒时间戳</td></tr>
|
||||
<tr><td>datetime</td><td><span class="type">string</span></td><td>ISO 8601 格式(末尾 Z)</td></tr>
|
||||
<tr><td>open</td><td><span class="type-num">float</span></td><td>开盘价</td></tr>
|
||||
<tr><td>high</td><td><span class="type-num">float</span></td><td>最高价</td></tr>
|
||||
<tr><td>low</td><td><span class="type-num">float</span></td><td>最低价</td></tr>
|
||||
<tr><td>close</td><td><span class="type-num">float</span></td><td>收盘价</td></tr>
|
||||
<tr><td>volume</td><td><span class="type-num">float</span></td><td>成交量</td></tr>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ GET /api/derivatives ============ -->
|
||||
<section id="derivatives">
|
||||
<h2>查询衍生品数据</h2>
|
||||
<div class="endpoint open">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="method get">GET</span>
|
||||
<span class="endpoint-path">/api/derivatives</span>
|
||||
<span class="endpoint-desc">获取资金费率、持仓量、基差</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
|
||||
<table>
|
||||
<tr><th>参数</th><th>类型</th><th>必填</th><th>说明</th></tr>
|
||||
<tr>
|
||||
<td>symbol</td>
|
||||
<td><span class="type">string</span></td>
|
||||
<td>否</td>
|
||||
<td>交易对,默认 <code>BTC/USDT:USDT</code></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="note">数据每 60 秒自动刷新,落盘到 <code>data/derivatives/</code> 目录。</div>
|
||||
|
||||
<h3>请求示例</h3>
|
||||
<pre><span class="comment"># 获取 BTC 衍生品数据</span>
|
||||
GET /api/derivatives?symbol=BTC/USDT:USDT</pre>
|
||||
|
||||
<h3>响应</h3>
|
||||
<pre>{
|
||||
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
<span class="key">"timestamp"</span>: <span class="num">1719705600000</span>,
|
||||
<span class="key">"datetime"</span>: <span class="string">"2024-06-30T00:00:00Z"</span>,
|
||||
<span class="key">"funding_rate"</span>: <span class="num">0.0001</span>,
|
||||
<span class="key">"open_interest"</span>: <span class="num">35120000000.0</span>,
|
||||
<span class="key">"oi_change_pct"</span>: <span class="num">3.52</span>,
|
||||
<span class="key">"basis"</span>: <span class="num">8.5</span>
|
||||
}</pre>
|
||||
|
||||
<h3>字段说明</h3>
|
||||
<table>
|
||||
<tr><th>字段</th><th>类型</th><th>说明</th></tr>
|
||||
<tr><td>funding_rate</td><td>float</td><td>当前资金费率(每 8 小时)</td></tr>
|
||||
<tr><td>open_interest</td><td>float</td><td>当前持仓量(USD)</td></tr>
|
||||
<tr><td>oi_change_pct</td><td>float</td><td>24 小时持仓量变化百分比</td></tr>
|
||||
<tr><td>basis</td><td>float</td><td>期货-现货年化基差(%)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ WebSocket ============ -->
|
||||
<section id="websocket">
|
||||
<h2>WebSocket 实时推送</h2>
|
||||
<div class="endpoint open">
|
||||
<div class="endpoint-header" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="method ws">WS</span>
|
||||
<span class="endpoint-path">/ws</span>
|
||||
<span class="endpoint-desc">实时 K 线订阅</span>
|
||||
</div>
|
||||
<div class="endpoint-body">
|
||||
|
||||
<p>连接 WebSocket 后,通过 JSON 消息进行订阅管理。服务端在数据更新时主动推送最新 K 线。</p>
|
||||
|
||||
<h3>客户端 → 服务端</h3>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">订阅 K 线</div>
|
||||
<pre>{
|
||||
<span class="key">"action"</span>: <span class="string">"subscribe"</span>,
|
||||
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">取消订阅</div>
|
||||
<pre>{
|
||||
<span class="key">"action"</span>: <span class="string">"unsubscribe"</span>,
|
||||
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">心跳 Ping</div>
|
||||
<pre>{ <span class="key">"action"</span>: <span class="string">"ping"</span> }</pre>
|
||||
</div>
|
||||
|
||||
<h3>服务端 → 客户端</h3>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">订阅确认</div>
|
||||
<pre>{
|
||||
<span class="key">"type"</span>: <span class="string">"subscribed"</span>,
|
||||
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">初始快照(订阅后立即推送最近 500 根 K 线)</div>
|
||||
<pre>{
|
||||
<span class="key">"type"</span>: <span class="string">"snapshot"</span>,
|
||||
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>,
|
||||
<span class="key">"data"</span>: [ ... ]
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">K 线更新(增量推送最近 2 根)</div>
|
||||
<pre>{
|
||||
<span class="key">"type"</span>: <span class="string">"kline"</span>,
|
||||
<span class="key">"symbol"</span>: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
<span class="key">"timeframe"</span>: <span class="string">"1m"</span>,
|
||||
<span class="key">"data"</span>: [ ... ]
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">Pong 响应</div>
|
||||
<pre>{ <span class="key">"type"</span>: <span class="string">"pong"</span> }</pre>
|
||||
</div>
|
||||
|
||||
<div class="ws-box">
|
||||
<div class="label">错误消息</div>
|
||||
<pre>{ <span class="key">"type"</span>: <span class="string">"error"</span>, <span class="key">"message"</span>: <span class="string">"..."</span> }</pre>
|
||||
</div>
|
||||
|
||||
<h3>JavaScript 示例</h3>
|
||||
<pre><span class="comment">// 连接</span>
|
||||
<span class="key">const</span> ws = <span class="string">new WebSocket("ws://localhost:9009/ws")</span>;
|
||||
|
||||
ws.<span class="key">onopen</span> = () => {
|
||||
<span class="comment">// 订阅 BTC 1m K 线</span>
|
||||
ws.send(JSON.stringify({
|
||||
action: <span class="string">"subscribe"</span>,
|
||||
symbol: <span class="string">"BTC/USDT:USDT"</span>,
|
||||
timeframe: <span class="string">"1m"</span>
|
||||
}));
|
||||
};
|
||||
|
||||
ws.<span class="key">onmessage</span> = (event) => {
|
||||
<span class="key">const</span> msg = JSON.parse(event.data);
|
||||
<span class="key">if</span> (msg.type === <span class="string">"kline"</span>) {
|
||||
console.log(msg.data); <span class="comment">// 最新 K 线数组</span>
|
||||
}
|
||||
};</pre>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 时间周期参考 ============ -->
|
||||
<section id="timeframes-ref">
|
||||
<h2>时间周期参考</h2>
|
||||
<p>以下是完整的周期对照表:</p>
|
||||
|
||||
<table>
|
||||
<tr><th>基础周期</th><th>合成衍生周期</th></tr>
|
||||
<tr><td><code>1m</code></td><td><code>2m, 3m, 4m, 5m, 10m, 15m, 20m, 25m, 30m, 45m</code></td></tr>
|
||||
<tr><td><code>1h</code></td><td><code>2h, 3h, 4h, 5h, 6h, 7h, 8h, 9h, 10h, 11h, 12h, 16h, 20h</code></td></tr>
|
||||
<tr><td><code>1d</code></td><td><code>2d, 3d, 4d, 5d, 6d</code></td></tr>
|
||||
<tr><td><code>1w</code></td><td><code>2w, 3w</code></td></tr>
|
||||
</table>
|
||||
|
||||
<p>衍生周期由对应基础周期的 K 线通过 OHLCV 聚合合成,查询方式与基础周期完全一致。</p>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Chan Data Provider — Built with FastAPI + ccxt + pandas
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<span class="comment">// 展开/折叠端点详情</span>
|
||||
document.querySelectorAll('.endpoint-header').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
el.parentElement.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
<span class="comment">// 默认展开所有端点</span>
|
||||
document.querySelectorAll('.endpoint').forEach(el => el.classList.add('open'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,28 +4,15 @@
|
||||
"BTC/USDT:USDT",
|
||||
"ETH/USDT:USDT",
|
||||
"SOL/USDT:USDT",
|
||||
"XAU/USDT:USDT",
|
||||
"XAG/USDT:USDT",
|
||||
"SAGA/USDT:USDT",
|
||||
"CL/USDT:USDT",
|
||||
"ZEC/USDT:USDT",
|
||||
"XRP/USDT:USDT",
|
||||
"DOGE/USDT:USDT",
|
||||
"BNB/USDT:USDT",
|
||||
"WIF/USDT:USDT",
|
||||
"AAVE/USDT:USDT",
|
||||
"SUI/USDT:USDT",
|
||||
"BILL/USDT:USDT",
|
||||
"BZ/USDT:USDT",
|
||||
"LAB/USDT:USDT",
|
||||
"TON/USDT:USDT",
|
||||
"CRCL/USDT:USDT",
|
||||
"SNDK/USDT:USDT",
|
||||
"1000PEPE/USDT:USDT",
|
||||
"CHIP/USDT:USDT"
|
||||
"1INCH/USDT:USDT",
|
||||
"DOGE/USDT:USDT",
|
||||
"UNI/USDT:USDT"
|
||||
],
|
||||
"start_time": "2024-01-01T00:00:00Z",
|
||||
"start_time_per_tf": {
|
||||
"1m": "2026-01-01T00:00:00Z"
|
||||
},
|
||||
"timeframes": ["1m", "1h", "1d", "1w"],
|
||||
"data_dir": "./data"
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ services:
|
||||
environment:
|
||||
CONFIG_PATH: /app/config.json
|
||||
UVICORN_HOST: 0.0.0.0
|
||||
UVICORN_PORT: "80"
|
||||
UVICORN_PORT: "9009"
|
||||
volumes:
|
||||
- ./config.json:/app/config.json:ro
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "80:80"
|
||||
- "9009:9009"
|
||||
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chan 数据提供商</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--orange: #d29922;
|
||||
--red: #f85149;
|
||||
--purple: #bc8cff;
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
--mono: "SF Mono", "Fira Code", "Consolas", monospace;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
header .brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
header .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #1c3d5a 0%, #2d1b5e 100%);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 20px; font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
header .brand h1 { font-size: 20px; font-weight: 600; }
|
||||
header .brand h1 span { color: var(--accent); }
|
||||
header .brand .sub { font-size: 12px; color: var(--text-secondary); }
|
||||
.header-time {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Overview cards */
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.ov-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.ov-card .ov-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
|
||||
.ov-card .ov-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ov-card .ov-value.green { color: var(--green); }
|
||||
.ov-card .ov-value.orange { color: var(--orange); }
|
||||
.ov-card .ov-value.accent { color: var(--accent); }
|
||||
.ov-card .ov-value.purple { color: var(--purple); }
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.section-header:hover { background: rgba(255,255,255,0.02); }
|
||||
.section-header h2 { font-size: 14px; font-weight: 600; }
|
||||
.section-header .count-badge {
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.section-body { padding: 8px 16px 12px; }
|
||||
|
||||
/* Status dot */
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot.green { background: var(--green); box-shadow: 0 0 6px #3fb95060; }
|
||||
.dot.orange { background: var(--orange); box-shadow: 0 0 6px #d2992260; }
|
||||
.dot.red { background: var(--red); box-shadow: 0 0 6px #f8514960; }
|
||||
.dot.gray { background: var(--text-secondary); }
|
||||
|
||||
/* Symbol row */
|
||||
.symbol-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(48,54,61,0.5);
|
||||
font-size: 14px;
|
||||
}
|
||||
.symbol-row:last-child { border-bottom: none; }
|
||||
.symbol-row .name { font-family: var(--mono); font-size: 13px; min-width: 140px; }
|
||||
.symbol-row .tag {
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(88,166,255,0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* TF chips */
|
||||
.tf-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.tf-chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tf-chip.base { border-color: #58a6ff40; color: var(--accent); }
|
||||
.tf-chip.derived { border-color: #bc8cff40; color: var(--purple); }
|
||||
|
||||
/* Links bar */
|
||||
.links-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
.links-bar a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.links-bar a:hover { border-color: var(--accent); }
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 16px 0;
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fade-in { animation: fadeIn 0.3s ease-in; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo">C</div>
|
||||
<div>
|
||||
<h1><span>Chan</span> 数据提供商</h1>
|
||||
<div class="sub">加密货币 K 线数据服务</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-time" id="header-time">—</div>
|
||||
</header>
|
||||
|
||||
<!-- Overview -->
|
||||
<div class="overview" id="overview">
|
||||
<div class="ov-card"><div class="ov-label">服务状态</div><div class="ov-value" id="ov-status">加载中...</div></div>
|
||||
<div class="ov-card"><div class="ov-label">交易所</div><div class="ov-value accent" id="ov-exchange">—</div></div>
|
||||
<div class="ov-card"><div class="ov-label">交易对</div><div class="ov-value" id="ov-symbols">—</div></div>
|
||||
<div class="ov-card"><div class="ov-label">基础周期</div><div class="ov-value accent" id="ov-base-tf">—</div></div>
|
||||
<div class="ov-card"><div class="ov-label">衍生周期</div><div class="ov-value purple" id="ov-derived-tf">—</div></div>
|
||||
<div class="ov-card"><div class="ov-label">数据就绪</div><div class="ov-value" id="ov-ready">—</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="links-bar">
|
||||
<a href="/api/docs">📖 API 文档</a>
|
||||
<a href="/docs">📋 Swagger UI</a>
|
||||
<a href="/redoc">📄 ReDoc</a>
|
||||
<a href="/api/candles?symbol=BTC/USDT:USDT&tf=1m&limit=5" target="_blank">📊 BTC 1m 示例</a>
|
||||
<a href="/api/candles?symbol=ETH/USDT:USDT&tf=4h&limit=10" target="_blank">📊 ETH 4h 示例</a>
|
||||
</div>
|
||||
|
||||
<!-- Symbols -->
|
||||
<div class="section" id="section-symbols">
|
||||
<div class="section-header" onclick="this.parentElement.classList.toggle('collapsed')">
|
||||
<h2>📈 交易对</h2>
|
||||
<span class="count-badge" id="sym-count">0</span>
|
||||
</div>
|
||||
<div class="section-body" id="symbol-list">
|
||||
<div style="color:var(--text-secondary);font-size:13px;">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeframes -->
|
||||
<div class="section">
|
||||
<div class="section-header" onclick="this.parentElement.classList.toggle('collapsed')">
|
||||
<h2>⏱ 时间周期</h2>
|
||||
<span class="count-badge" id="tf-total">0</span>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div style="margin-bottom:8px;font-size:13px;color:var(--text-secondary);">基础周期(交易所直拉)</div>
|
||||
<div class="tf-list" id="base-tf-list"></div>
|
||||
<div style="margin:10px 0 8px;font-size:13px;color:var(--text-secondary);">衍生周期(内存合成)</div>
|
||||
<div class="tf-list" id="derived-tf-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick query -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>⚡ 快速查询</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end;">
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">交易对</div>
|
||||
<select id="q-symbol" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-family:var(--mono);font-size:13px;"></select>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">周期</div>
|
||||
<select id="q-tf" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-family:var(--mono);font-size:13px;"></select>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">数量</div>
|
||||
<select id="q-limit" style="background:var(--bg);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:6px;font-family:var(--mono);font-size:13px;">
|
||||
<option>5</option><option selected>10</option><option>20</option><option>50</option>
|
||||
</select>
|
||||
</div>
|
||||
<button onclick="quickQuery()" style="background:var(--accent);color:#fff;border:none;padding:6px 18px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600;">查询</button>
|
||||
</div>
|
||||
<pre id="q-result" style="margin-top:10px;display:none;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Chan Data Provider · Built with FastAPI + ccxt + pandas ·
|
||||
更新于 <span id="footer-time">—</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
let healthData = null;
|
||||
|
||||
function fmtTime(ts) {
|
||||
return new Date(ts).toLocaleString('zh-CN', { timeZone: 'UTC', hour12: false }) + ' UTC';
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const res = await fetch('/health');
|
||||
healthData = await res.json();
|
||||
renderHealth(healthData);
|
||||
} catch {
|
||||
document.getElementById('ov-status').textContent = '无法连接';
|
||||
document.getElementById('ov-status').style.color = 'var(--red)';
|
||||
document.getElementById('ov-ready').textContent = '断开';
|
||||
document.getElementById('ov-ready').style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealth(d) {
|
||||
const now = Date.now();
|
||||
document.getElementById('header-time').textContent = fmtTime(now);
|
||||
document.getElementById('footer-time').textContent = fmtTime(now);
|
||||
|
||||
// Overview
|
||||
const statusEl = document.getElementById('ov-status');
|
||||
statusEl.textContent = '运行中';
|
||||
statusEl.style.color = 'var(--green)';
|
||||
|
||||
document.getElementById('ov-exchange').textContent = d.exchange || '—';
|
||||
|
||||
const symCount = (d.symbols || []).length;
|
||||
const symEl = document.getElementById('ov-symbols');
|
||||
symEl.textContent = symCount + ' 个';
|
||||
symEl.style.color = 'var(--accent)';
|
||||
|
||||
document.getElementById('ov-base-tf').textContent = (d.base_timeframes || []).length + ' 个';
|
||||
document.getElementById('ov-derived-tf').textContent = (d.derived_timeframes || []).length + ' 个';
|
||||
|
||||
const readyEl = document.getElementById('ov-ready');
|
||||
if (d.ready) {
|
||||
readyEl.textContent = '已就绪';
|
||||
readyEl.style.color = 'var(--green)';
|
||||
} else {
|
||||
readyEl.textContent = '同步中...';
|
||||
readyEl.style.color = 'var(--orange)';
|
||||
setTimeout(loadHealth, 2000);
|
||||
}
|
||||
|
||||
// Symbols
|
||||
const symList = document.getElementById('symbol-list');
|
||||
const symCountEl = document.getElementById('sym-count');
|
||||
symCountEl.textContent = symCount;
|
||||
if (d.symbols && d.symbols.length > 0) {
|
||||
symList.innerHTML = d.symbols.map(s => `
|
||||
<div class="symbol-row">
|
||||
<span class="dot green"></span>
|
||||
<span class="name">${s}</span>
|
||||
<span class="tag">${d.exchange || '—'}</span>
|
||||
<a href="/api/candles?symbol=${encodeURIComponent(s)}&tf=1m&limit=5" target="_blank" style="margin-left:auto;font-size:12px;color:var(--accent);text-decoration:none;">1m →</a>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
symList.innerHTML = '<div style="color:var(--text-secondary);font-size:13px;">暂无交易对</div>';
|
||||
}
|
||||
|
||||
// Timeframes
|
||||
document.getElementById('tf-total').textContent = (d.timeframes || []).length;
|
||||
|
||||
const baseList = document.getElementById('base-tf-list');
|
||||
if (d.base_timeframes) {
|
||||
baseList.innerHTML = d.base_timeframes.map(t => `<span class="tf-chip base">${t}</span>`).join('');
|
||||
}
|
||||
|
||||
const derivedList = document.getElementById('derived-tf-list');
|
||||
if (d.derived_timeframes) {
|
||||
derivedList.innerHTML = d.derived_timeframes.map(t => `<span class="tf-chip derived">${t}</span>`).join('');
|
||||
}
|
||||
|
||||
// Populate query selects
|
||||
const symSelect = document.getElementById('q-symbol');
|
||||
if (d.symbols && symSelect.options.length === 0) {
|
||||
d.symbols.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s;
|
||||
opt.textContent = s;
|
||||
symSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
const tfSelect = document.getElementById('q-tf');
|
||||
if (d.timeframes && tfSelect.options.length === 0) {
|
||||
d.timeframes.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t;
|
||||
opt.textContent = t;
|
||||
tfSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function quickQuery() {
|
||||
const symbol = document.getElementById('q-symbol').value;
|
||||
const tf = document.getElementById('q-tf').value;
|
||||
const limit = document.getElementById('q-limit').value;
|
||||
const url = `/api/candles?symbol=${encodeURIComponent(symbol)}&tf=${tf}&limit=${limit}`;
|
||||
const pre = document.getElementById('q-result');
|
||||
pre.style.display = 'block';
|
||||
pre.textContent = '查询中...';
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
pre.textContent = JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
pre.textContent = '查询失败';
|
||||
}
|
||||
}
|
||||
|
||||
loadHealth();
|
||||
setInterval(loadHealth, 10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+126
-1051
File diff suppressed because it is too large
Load Diff
@@ -1,321 +0,0 @@
|
||||
# 1分钟第三类买卖点策略
|
||||
|
||||
## 核心思路
|
||||
|
||||
只交易 1 分钟级别中枢之后确认完成的第三类买卖点。
|
||||
|
||||
- 第三类买点:价格向上离开 1 分钟中枢后,回拉笔低点不跌回中枢上沿,确认时做多。
|
||||
- 第三类卖点:价格向下离开 1 分钟中枢后,反弹笔高点不涨回中枢下沿,确认时做空。
|
||||
- 开单时机:第三类买卖点所在笔确认完成后,下一根 1 分钟 K 线开单,避免使用未确认信号。
|
||||
|
||||
## 初始量化参数
|
||||
|
||||
以下参数作为第一版回测基准,后续根据回测结果优化。
|
||||
|
||||
| 参数 | 初始值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 基础周期 | 1m | 第三类买卖点识别周期 |
|
||||
| 中枢算法 | 纯笔中枢 | 连续三笔重叠形成中枢,两个中枢允许相邻,不强制中间分割笔 |
|
||||
| 大周期过滤 | 5m、15m | 用于判断趋势方向和过滤震荡 |
|
||||
| ATR 周期 | 14 | 用于衡量离开力度、回抽深度和止损距离 |
|
||||
| 成交量均线 | 20 | 用于判断离开放量和回抽缩量 |
|
||||
| 最小中枢宽度 | 0.08% | 低于该值视为噪音中枢 |
|
||||
| 最大中枢宽度 | 0.80% | 高于该值止损过宽,放弃交易 |
|
||||
| 有效突破距离 | max(0.03%, 0.20 * ATR14 / close) | 离开中枢时收盘价需要超过边界的最小距离 |
|
||||
| 离开笔最小幅度 | max(0.12%, 1.00 * ATR14 / close) | 过滤力度不足的离开笔 |
|
||||
| 回抽最大距离 | 0.60 * ATR14 | 回抽/反弹离中枢边界太远时,不追单 |
|
||||
| 离开放量 | volume >= 1.20 * volume_ma20 | 确认突破有主动资金 |
|
||||
| 回抽缩量 | pullback_volume <= 0.90 * leave_volume | 确认回抽不是反向强攻击 |
|
||||
| 最大止损距离 | 0.80% | 超过则放弃交易 |
|
||||
| 最小止损距离 | 0.10% | 低于则容易被 1m 噪音扫损 |
|
||||
| 单笔风险 | 0.5% - 1.0% | 每笔亏损控制在账户权益比例内 |
|
||||
| 时间止损 | 8 根 1m K 线 | 开仓后 8 分钟仍未到 0.5R,主动减仓或平仓 |
|
||||
| 连续失败暂停 | 2 次 | 连续 2 次三买/三卖失败后暂停 30 分钟 |
|
||||
|
||||
## 信号有效条件
|
||||
|
||||
### 中枢要求
|
||||
|
||||
- 中枢必须已经确认,不能用正在形成中的中枢。
|
||||
- 使用 1 分钟纯笔中枢:连续三笔有重叠区间即可形成中枢,后续按两笔一组延伸。
|
||||
- 两个中枢可以在笔序列上直接相邻,不要求中间必须有独立分割笔。
|
||||
- 新中枢在旧中枢下方时,必须以向上笔开始并以向上笔结束,避免把下跌途中的弱反抽误当成有效下移中枢。
|
||||
- 新中枢在旧中枢上方时,必须以向下笔开始并以向下笔结束,避免把上涨途中的弱回踩误当成有效上移中枢。
|
||||
- 中枢宽度控制在 0.08% - 0.80% 之间,太小容易是假突破,太大导致止损距离过宽。
|
||||
- 优先选择结构清晰、震荡时间充分、上下沿明确的中枢。
|
||||
- 中枢层只做结构合法性判断,不因为成交量、离开力度、回抽质量等交易偏好直接删除中枢;这些质量条件放到买卖点确认和入场过滤中处理。
|
||||
|
||||
### 离开中枢要求
|
||||
|
||||
- 做多时,离开笔必须向上有效突破中枢上沿。
|
||||
- 做空时,离开笔必须向下有效跌破中枢下沿。
|
||||
- 有效突破要求收盘价至少超过中枢边界 max(0.03%, 0.20 * ATR14 / close)。
|
||||
- 离开笔幅度至少达到 max(0.12%, 1.00 * ATR14 / close)。
|
||||
- 离开笔成交量至少达到 1.20 * volume_ma20。
|
||||
- MACD 柱子方向需要和离开方向一致,做多时 macdhist > 0,做空时 macdhist < 0。
|
||||
- 如果离开中枢后很快又回到中枢内部,视为假突破,不开单。
|
||||
|
||||
### 回抽/反弹要求
|
||||
|
||||
- 做多时,回抽低点不能跌回中枢上沿下方。
|
||||
- 做空时,反弹高点不能涨回中枢下沿上方。
|
||||
- 回抽/反弹允许 0.15 * ATR14 的刺破容忍,避免被 1m 假刺破过滤掉。
|
||||
- 回抽/反弹距离中枢边界不能超过 0.60 * ATR14,超过说明已经追远。
|
||||
- 回抽/反弹成交量需要小于离开笔成交量的 90%。
|
||||
- 回抽/反弹 K 线数量建议控制在 2 - 8 根 1m K 线内,太短容易没确认,太长说明力度衰减。
|
||||
|
||||
## 行情过滤
|
||||
|
||||
### 震荡行情
|
||||
|
||||
震荡行情尽量不做第三类买卖点,因为 1 分钟级别假突破很多。
|
||||
|
||||
过滤方式:
|
||||
|
||||
- 1 分钟只负责寻找第三类买卖点,5 分钟优先负责判断是否接受该信号。
|
||||
- 5 分钟和 15 分钟方向不一致时不做。
|
||||
- 5 分钟最近中枢仍在横向扩张、价格仍在 5 分钟中枢内部时,降低 1 分钟三买/三卖信号优先级,或直接不做突破类信号。
|
||||
- 做多信号优先要求 5 分钟中枢上移或价格位于 5 分钟中枢上沿附近/上方;做空信号优先要求 5 分钟中枢下移或价格位于 5 分钟中枢下沿附近/下方。
|
||||
- 价格反复穿越 EMA24/EMA52 时不做。
|
||||
- 中枢上下沿附近频繁出现假突破时不做。
|
||||
- 最近 30 分钟内出现 2 次同方向三买/三卖失败时,暂停该方向交易 30 分钟。
|
||||
- 最近 20 根 1m K 线内,收盘价穿越 EMA52 超过 4 次,视为震荡,不做。
|
||||
- ATR14 / close 低于 0.05% 时,波动不足,不做。
|
||||
|
||||
### 趋势开始阶段
|
||||
|
||||
趋势刚开始时的第一个有效三买/三卖优先级最高。
|
||||
|
||||
做多条件:
|
||||
|
||||
- 5 分钟或 15 分钟开始转多,至少满足 close > EMA52。
|
||||
- 1 分钟向上离开中枢有力度。
|
||||
- 回抽不跌回中枢,且回抽缩量。
|
||||
|
||||
做空条件:
|
||||
|
||||
- 5 分钟或 15 分钟开始转空,至少满足 close < EMA52。
|
||||
- 1 分钟向下离开中枢有力度。
|
||||
- 反弹不涨回中枢,且反弹缩量。
|
||||
|
||||
### 趋势中期
|
||||
|
||||
趋势中期可以继续做顺势三买/三卖,但需要提高过滤要求。
|
||||
|
||||
- 只做顺大周期方向的信号。
|
||||
- 做多时 5 分钟 close > EMA24 > EMA52,且 15 分钟 close > EMA52。
|
||||
- 做空时 5 分钟 close < EMA24 < EMA52,且 15 分钟 close < EMA52。
|
||||
- 如果止损距离超过 0.80%,放弃交易。
|
||||
- 趋势中期的同方向第二个及之后三买/三卖,仓位降为标准仓位的 50%。
|
||||
|
||||
### 趋势末期
|
||||
|
||||
趋势末期减少追单,重点防止三买买在高点、三卖卖在低点。
|
||||
|
||||
不交易条件:
|
||||
|
||||
- 离开中枢时 MACD 或成交量明显背驰。
|
||||
- 已经连续出现多个同方向中枢上移/下移。
|
||||
- 出现反向第一类或第二类买卖点。
|
||||
- 价格远离 5 分钟 EMA52 超过 max(1.20%, 2.50 * ATR14 / close),短线加速过度。
|
||||
- 连续 3 个同方向中枢上移/下移后,不再追新的 1m 三买/三卖。
|
||||
|
||||
## 特殊点位处理
|
||||
|
||||
### 第一类和第二类买卖点之后
|
||||
|
||||
如果出现第一类或第二类买卖点后,行情没有继续确认反转,而是重新形成第三类买卖点:
|
||||
|
||||
- 顺原趋势的第三类买卖点可以继续做,但必须确认反向一二类买卖点失败。
|
||||
- 如果一类/二类买卖点之后形成更大级别反转结构,不再做原方向三买/三卖。
|
||||
- 如果一类/二类买卖点和三类买卖点方向冲突,以大周期方向和最新确认结构为准。
|
||||
|
||||
### 反向信号
|
||||
|
||||
- 持有多单时出现确认的第三类卖点,平多;如果大周期也转空,可以反手做空。
|
||||
- 持有空单时出现确认的第三类买点,平空;如果大周期也转多,可以反手做多。
|
||||
|
||||
## 开仓规则
|
||||
|
||||
### 做多
|
||||
|
||||
同时满足以下条件才开多:
|
||||
|
||||
- 出现确认后的 1 分钟第三类买点。
|
||||
- 5 分钟或 15 分钟趋势不为空头。
|
||||
- 价格没有重新跌回中枢内部。
|
||||
- 初始止损距离在可接受范围内。
|
||||
- 没有明显背驰或趋势末期信号。
|
||||
- 开仓价距离中枢上沿不超过 0.60 * ATR14。
|
||||
- 止损距离在 0.10% - 0.80% 之间。
|
||||
|
||||
### 做空
|
||||
|
||||
同时满足以下条件才开空:
|
||||
|
||||
- 出现确认后的 1 分钟第三类卖点。
|
||||
- 5 分钟或 15 分钟趋势不为多头。
|
||||
- 价格没有重新涨回中枢内部。
|
||||
- 初始止损距离在可接受范围内。
|
||||
- 没有明显背驰或趋势末期信号。
|
||||
- 开仓价距离中枢下沿不超过 0.60 * ATR14。
|
||||
- 止损距离在 0.10% - 0.80% 之间。
|
||||
|
||||
## 信号失效
|
||||
|
||||
- 第三类买点确认后,价格重新跌回中枢上沿下方,信号失效。
|
||||
- 第三类卖点确认后,价格重新涨回中枢下沿上方,信号失效。
|
||||
- 开仓后 8 根 1 分钟 K 线仍未达到 0.5R,说明信号弱,可以主动减仓或平仓。
|
||||
- 开仓后 3 根 1 分钟 K 线内直接回到中枢内部,立即平仓。
|
||||
- 出现反向确认信号时,当前持仓失效。
|
||||
|
||||
## 止盈止损
|
||||
|
||||
### 止损
|
||||
|
||||
- 做多止损:放在中枢下沿,或第三类买点回抽低点下方。
|
||||
- 做空止损:放在中枢上沿,或第三类卖点反弹高点上方。
|
||||
- 止损需要额外留出 0.10 * ATR14 的缓冲,避免刚好打在结构边界。
|
||||
- 如果止损距离大于 0.80%,不开仓。
|
||||
- 如果止损距离小于 0.10%,按 0.10% 计算仓位风险,避免仓位过大。
|
||||
- 如果价格重新回到中枢内部,优先考虑提前止损,不等硬止损。
|
||||
|
||||
### 止盈
|
||||
|
||||
按照风险收益比管理:
|
||||
|
||||
- 到达 1R 时平仓一半。
|
||||
- 到达 1R 后,剩余仓位止损移动到开仓价。
|
||||
- 到达 2R 时全部止盈。
|
||||
- 如果趋势特别强,可以在 2R 附近保留小仓位,用 EMA24 或前一笔低/高点跟踪止盈。
|
||||
|
||||
### 仓位
|
||||
|
||||
- 标准单笔风险控制在账户权益的 0.5% - 1.0%。
|
||||
- 趋势开始阶段使用标准仓位。
|
||||
- 趋势中期第二个及之后同方向三买/三卖使用 50% 标准仓位。
|
||||
- 趋势末期不主动开新仓。
|
||||
|
||||
## 参数优化方法
|
||||
|
||||
这些参数不能只看单次回测收益率,需要用历史数据做分阶段优化和样本外验证。
|
||||
|
||||
### 数据切分
|
||||
|
||||
建议至少使用 6 - 12 个月 1m 数据,按时间顺序切分,不能随机打乱。
|
||||
|
||||
- 训练集:前 60%,用于搜索参数。
|
||||
- 验证集:中间 20%,用于选择参数。
|
||||
- 测试集:最后 20%,只用于最终确认,不参与调参。
|
||||
|
||||
例如:
|
||||
|
||||
- 2025-01 到 2025-06:训练集。
|
||||
- 2025-07 到 2025-08:验证集。
|
||||
- 2025-09 到 2025-10:测试集。
|
||||
|
||||
如果数据足够多,建议再做滚动验证:
|
||||
|
||||
- 第 1 轮:1 - 3 月训练,4 月验证。
|
||||
- 第 2 轮:2 - 4 月训练,5 月验证。
|
||||
- 第 3 轮:3 - 5 月训练,6 月验证。
|
||||
- 只有多轮都稳定的参数,才认为有效。
|
||||
|
||||
### 优先优化的参数
|
||||
|
||||
不要一次优化太多参数,先优化最影响胜率和盈亏比的核心参数。
|
||||
|
||||
| 参数 | 搜索范围 | 步长 | 优化目的 |
|
||||
| --- | --- | --- | --- |
|
||||
| 最小中枢宽度 | 0.05% - 0.15% | 0.02% | 过滤噪音中枢 |
|
||||
| 最大中枢宽度 | 0.50% - 1.20% | 0.10% | 控制止损距离 |
|
||||
| 有效突破距离 | 0.10 - 0.40 * ATR14 | 0.05 | 过滤假突破 |
|
||||
| 离开笔最小幅度 | 0.80 - 1.50 * ATR14 | 0.10 | 确认离开力度 |
|
||||
| 回抽容忍幅度 | 0.05 - 0.25 * ATR14 | 0.05 | 避免过严或过松 |
|
||||
| 回抽最大距离 | 0.40 - 0.90 * ATR14 | 0.10 | 避免追高追低 |
|
||||
| 离开放量倍数 | 1.00 - 1.80 * volume_ma20 | 0.10 | 确认突破质量 |
|
||||
| 回抽缩量比例 | 0.70 - 1.00 * leave_volume | 0.05 | 判断回抽是否健康 |
|
||||
| 最大止损距离 | 0.50% - 1.20% | 0.10% | 控制单笔风险 |
|
||||
| 时间止损 K 线数 | 5 - 15 根 | 1 | 处理无效信号 |
|
||||
|
||||
第一轮只优化这些参数。大周期过滤、仓位、止盈方式先固定,否则容易过拟合。
|
||||
|
||||
### 优化目标
|
||||
|
||||
不要只按总收益选择参数。1 分钟策略噪音大,应该综合看:
|
||||
|
||||
- 样本外收益为正。
|
||||
- 最大回撤尽量小。
|
||||
- Profit Factor 大于 1.20。
|
||||
- 胜率不低于 40%,如果胜率低,则平均盈亏比必须明显高于 1.5。
|
||||
- 单月交易次数不能太少,建议每月至少 20 笔,否则统计意义不足。
|
||||
- 多空两边不能严重失衡,除非策略明确只适合单边行情。
|
||||
|
||||
参数选择优先级:
|
||||
|
||||
1. 样本外稳定性。
|
||||
2. 最大回撤。
|
||||
3. Profit Factor。
|
||||
4. 平均盈亏比。
|
||||
5. 总收益率。
|
||||
|
||||
### 防止过拟合
|
||||
|
||||
以下情况说明参数可能过拟合:
|
||||
|
||||
- 训练集收益很好,验证集和测试集明显变差。
|
||||
- 只有某一个月表现很好,其他月份表现一般。
|
||||
- 参数落在搜索范围边界,例如最大止损距离优化后总是取最大值。
|
||||
- 交易次数太少,靠少数几笔大盈利撑起收益。
|
||||
- 多次微调后收益提升,但回撤和稳定性变差。
|
||||
|
||||
处理方式:
|
||||
|
||||
- 选择参数平台区间,不选单个尖峰最优值。
|
||||
- 如果 0.20 * ATR、0.25 * ATR、0.30 * ATR 表现接近,优先选中间值。
|
||||
- 验证集表现比训练集差很多时,降低参数复杂度。
|
||||
- 每次只优化一组相关参数,例如先优化中枢和突破,再优化止损止盈。
|
||||
|
||||
### 推荐优化顺序
|
||||
|
||||
1. 先只测原始第三类买卖点,得到基准胜率和盈亏比。
|
||||
2. 加入中枢宽度过滤,观察交易次数和假突破是否下降。
|
||||
3. 加入离开力度和成交量过滤,优化胜率。
|
||||
4. 加入回抽质量过滤,减少追高追低。
|
||||
5. 加入大周期 EMA 过滤,观察震荡行情亏损是否下降。
|
||||
6. 优化止损距离和时间止损。
|
||||
7. 最后比较止盈方式:固定 2R、1R 减半 2R 全平、2R 后跟踪止盈。
|
||||
|
||||
每一步都要和上一步对比,只保留能提升样本外表现的过滤条件。
|
||||
|
||||
### 回测命令示例
|
||||
|
||||
先跑固定参数基准:
|
||||
|
||||
```bash
|
||||
freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250101-20250630
|
||||
```
|
||||
|
||||
再按训练集、验证集、测试集分别跑:
|
||||
|
||||
```bash
|
||||
freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250101-20250630
|
||||
freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250701-20250831
|
||||
freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250901-20251031
|
||||
```
|
||||
|
||||
如果后续把参数写成 Freqtrade 的可优化参数,可以使用 hyperopt 搜索核心参数,但最终仍然要用样本外测试集确认。
|
||||
|
||||
## 回测观察指标
|
||||
|
||||
回测时重点观察:
|
||||
|
||||
- 三买和三卖分别的胜率。
|
||||
- 趋势开始、中期、末期三个阶段的收益差异。
|
||||
- 止损距离过大的交易是否拖累整体收益。
|
||||
- 震荡行情中过滤条件是否能减少假突破。
|
||||
- 1R 减半和 2R 全平是否优于一次性止盈。
|
||||
|
||||
## 策略总结
|
||||
|
||||
这套策略只做确认后的 1 分钟第三类买卖点,不提前猜测。1 分钟纯笔中枢负责保留足够完整的结构事实,允许相邻中枢连续出现;交易层再通过大周期方向、中枢宽度、离开力度、回抽质量和止损距离过滤掉低质量三买三卖。核心不是在中枢层过早删除结构,而是让 1 分钟找点、5 分钟定环境。
|
||||
@@ -1,133 +0,0 @@
|
||||
# --- Do not remove these libs ---
|
||||
from statistics import median
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_absolute
|
||||
import sys
|
||||
import os
|
||||
# 添加父目录到系统路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ChanLun import ChanLun
|
||||
from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE
|
||||
# --------------------------------
|
||||
from technical.util import resample_to_interval, resampled_merge
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
### Now you can use logger.info('asfd') to log
|
||||
# freqtrade plot-dataframe --strategy ChanLun_BTC_1m --datadir user_data/data/binance -c ./user_data/ChanLun_SOL_30.json --timerange=20250309-
|
||||
|
||||
# freqtrade trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20260501-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json -t 1m 1m 1h 1d 1M --pairs BTC/USDT:USDT --timerange=20250405-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json -t 1m 1h 1d 1M --pairs BTC/USDT --timerange=20170101-
|
||||
# freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/ChanLun_BTC_1m.json -e 200 --timerange=20250201-20250901
|
||||
# freqtrade edge -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange 20250721-20250901
|
||||
# freqtrade plot-dataframe -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange 20250721-20250901
|
||||
|
||||
# sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250721-
|
||||
# sudo docker compose run --rm chanlun_btc download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json --pairs BTC/USDT:USDT -t 1m --timerange 20240101-
|
||||
# sudo docker compose run --rm chanlun_btc trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
|
||||
|
||||
class ChanLun_BTC_1m(IStrategy):
|
||||
"""
|
||||
交易核心(缠论):
|
||||
- 仅在缠论一/二/三类买卖点出现时交易。
|
||||
- 信号触发条件:前一笔被确认(bi.is_sure)时,该笔 end_klc 已被标记为 B1/B2/B3 或 S1/S2/S3。
|
||||
- 不使用未确认笔,不使用“状态猜测”列。
|
||||
"""
|
||||
INTERFACE_VERSION: int = 3
|
||||
# Minimal ROI designed for the strategy.
|
||||
# This attribute will be overridden if the config file contains "minimal_roi"
|
||||
# 30m and 1h
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.05,
|
||||
"60": 0.03,
|
||||
"120": 0.01,
|
||||
"180": 0
|
||||
}
|
||||
# 5m and 15m
|
||||
minimal_roi_1 = {
|
||||
"0": 0.1,
|
||||
"60": 0.05,
|
||||
"120": 0.02,
|
||||
"240": 0
|
||||
}
|
||||
# 15m and 30m
|
||||
minimal_roi_1 = {
|
||||
"0": 0.1,
|
||||
"240": 0.05,
|
||||
"480": 0.03,
|
||||
"600": 0
|
||||
}
|
||||
minimal_roi_1 = {
|
||||
"0": 1.50,
|
||||
"120": 0.05,
|
||||
"240": 0.025,
|
||||
"360": 0
|
||||
}
|
||||
|
||||
can_short = True
|
||||
lev = 1.0
|
||||
stoploss = -0.3 # 设置为很大的负值,让custom_stoploss来控制
|
||||
|
||||
trailing_stop = False
|
||||
trailing_stop_positive = 0.03
|
||||
trailing_stop_positive_offset = 0.06
|
||||
trailing_only_offset_is_reached = False
|
||||
|
||||
# 关闭分批止盈/仓位调整
|
||||
startup_candle_count = 500
|
||||
# 以 1m 为基础周期时,1h = 60 根K线(用于读取 resample_60_* 列并做确认延迟)
|
||||
chan = ChanLun()
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe = self.add_indicators(dataframe)
|
||||
dataframe['bsp_state'] = self.chan.get_bsp_state(dataframe)
|
||||
return dataframe
|
||||
def add_indicators(self, df):
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||
df['macd'] = macd['macd']
|
||||
df['macdsignal'] = macd['macdsignal']
|
||||
df['macdhist'] = macd['macdhist']
|
||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == -1)
|
||||
),
|
||||
['enter_long', 'enter_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == 1)
|
||||
),
|
||||
['enter_short', 'enter_tag']] = (1, 'short_signal_chan')
|
||||
return dataframe
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
# 出场和进场共用同一套“确认笔 + end_klc 买卖点”语义。
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == 1)
|
||||
),
|
||||
['exit_long', 'exit_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == -1)
|
||||
),
|
||||
['exit_short', 'exit_tag']] = (1, 'short_signal_chan')
|
||||
return dataframe
|
||||
def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str,
|
||||
**kwargs) -> float:
|
||||
return self.lev
|
||||
def get_ticker_indicator(self):
|
||||
return int(self.timeframe[:-1])
|
||||
@@ -1,213 +0,0 @@
|
||||
# --- Do not remove these libs ---
|
||||
from statistics import median
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_absolute
|
||||
import sys
|
||||
import os
|
||||
# 添加父目录到系统路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ChanLun import ChanLun
|
||||
from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE
|
||||
# --------------------------------
|
||||
from technical.util import resample_to_interval, resampled_merge
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
### Now you can use logger.info('asfd') to log
|
||||
# freqtrade plot-dataframe --strategy ChanLun_BTC_1m --datadir user_data/data/binance -c ./user_data/ChanLun_SOL_30.json --timerange=20250309-
|
||||
|
||||
# freqtrade trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20260501-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json -t 1m 1m 1h 1d 1M --pairs BTC/USDT:USDT --timerange=20250405-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json -t 1m 1h 1d 1M --pairs BTC/USDT --timerange=20170101-
|
||||
# freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/ChanLun_BTC_1m.json -e 200 --timerange=20250201-20250901
|
||||
# freqtrade edge -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange 20250721-20250901
|
||||
# freqtrade plot-dataframe -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange 20250721-20250901
|
||||
|
||||
# sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250721-
|
||||
# sudo docker compose run --rm chanlun_btc download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json --pairs BTC/USDT:USDT -t 1m --timerange 20240101-
|
||||
# sudo docker compose run --rm chanlun_btc trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
|
||||
|
||||
class ChanLun_BTC_1m_old(IStrategy):
|
||||
"""
|
||||
交易核心(缠论):
|
||||
- 仅在缠论一/二/三类买卖点出现时交易。
|
||||
- 信号触发条件:前一笔被确认(bi.is_sure)时,该笔 end_klc 已被标记为 B1/B2/B3 或 S1/S2/S3。
|
||||
- 不使用未确认笔,不使用“状态猜测”列。
|
||||
"""
|
||||
INTERFACE_VERSION: int = 3
|
||||
timeframe = '1m'
|
||||
# Minimal ROI designed for the strategy.
|
||||
# This attribute will be overridden if the config file contains "minimal_roi"
|
||||
minimal_roi = {
|
||||
"0": 100
|
||||
}
|
||||
|
||||
can_short = True
|
||||
enable_long = True
|
||||
enable_short = False
|
||||
lev = 1.0
|
||||
stoploss = -0.3 # 兜底止损,实际由 custom_stoploss 基于中枢 zg/zd 控制
|
||||
use_custom_stoploss = True
|
||||
|
||||
trailing_stop = False
|
||||
trailing_stop_positive = 0.03
|
||||
trailing_stop_positive_offset = 0.06
|
||||
trailing_only_offset_is_reached = False
|
||||
use_exit_signal = True
|
||||
position_adjustment_enable = True
|
||||
startup_candle_count = 500
|
||||
# 以 1m 为基础周期时,1h = 60 根K线(用于读取 resample_60_* 列并做确认延迟)
|
||||
chan = ChanLun()
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe = self.add_indicators(dataframe)
|
||||
bsp_signal_data = self.chan.get_bsp_signal_data(dataframe)
|
||||
for column, values in bsp_signal_data.items():
|
||||
dataframe[column] = values
|
||||
return dataframe
|
||||
def add_indicators(self, df):
|
||||
df = self.add_base_indicators(df)
|
||||
base_interval = self.get_ticker_indicator()
|
||||
for interval in (5, 15, 60):
|
||||
if interval <= base_interval:
|
||||
df = self.copy_base_indicators_to_resample(df, interval)
|
||||
continue
|
||||
resampled = resample_to_interval(df, interval)
|
||||
resampled = self.add_base_indicators(resampled)
|
||||
df = resampled_merge(df, resampled)
|
||||
return df
|
||||
def copy_base_indicators_to_resample(self, df, interval):
|
||||
prefix = f'resample_{interval}_'
|
||||
for column in (
|
||||
'date', 'open', 'high', 'low', 'close', 'volume',
|
||||
'atr', 'macd', 'macdsignal', 'macdhist', 'ema24', 'ema52',
|
||||
'atr_ratio', 'resistance_240', 'support_240', 'trend'
|
||||
):
|
||||
if column in df.columns:
|
||||
df[f'{prefix}{column}'] = df[column]
|
||||
return df
|
||||
def add_base_indicators(self, df):
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||
df['macd'] = macd['macd']
|
||||
df['macdsignal'] = macd['macdsignal']
|
||||
df['macdhist'] = macd['macdhist']
|
||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
||||
df['atr_ratio'] = df['atr'] / df['close']
|
||||
df['resistance_240'] = df['high'].rolling(240).max().shift(1)
|
||||
df['support_240'] = df['low'].rolling(240).min().shift(1)
|
||||
df['trend'] = 0
|
||||
df.loc[(df['close'] > df['ema52']) & (df['ema24'] >= df['ema52']), 'trend'] = 1
|
||||
df.loc[(df['close'] < df['ema52']) & (df['ema24'] <= df['ema52']), 'trend'] = -1
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
min_atr_ratio = 0.0005
|
||||
long_min_sr_distance_r = 1.0
|
||||
short_min_sr_distance_r = 0.8
|
||||
long_space_ratio = (dataframe['resistance_240'].shift(1) - dataframe['close'].shift(1)) / dataframe['close'].shift(1)
|
||||
short_space_ratio = (dataframe['close'].shift(1) - dataframe['support_240'].shift(1)) / dataframe['close'].shift(1)
|
||||
# 多周期趋势共振:3个周期中至少2个同向(而非全部3个)
|
||||
long_tf_aligned = (
|
||||
(dataframe['resample_5_trend'].shift(1) == 1).astype(int) +
|
||||
(dataframe['resample_15_trend'].shift(1) == 1).astype(int) +
|
||||
(dataframe['resample_60_trend'].shift(1) == 1).astype(int)
|
||||
) >= 2
|
||||
short_tf_aligned = (
|
||||
(dataframe['resample_5_trend'].shift(1) == -1).astype(int) +
|
||||
(dataframe['resample_15_trend'].shift(1) == -1).astype(int) +
|
||||
(dataframe['resample_60_trend'].shift(1) == -1).astype(int)
|
||||
) >= 2
|
||||
dataframe.loc[
|
||||
(
|
||||
self.enable_long &
|
||||
(dataframe['bsp_state'].shift(1) == -1) &
|
||||
(dataframe['bsp_risk_ratio'].shift(1) > 0) &
|
||||
(long_space_ratio >= dataframe['bsp_risk_ratio'].shift(1) * long_min_sr_distance_r) &
|
||||
(dataframe['macdhist'].shift(1) > 0) &
|
||||
(dataframe['atr_ratio'].shift(1) >= min_atr_ratio) &
|
||||
(dataframe['trend'].shift(1) == 1) &
|
||||
long_tf_aligned
|
||||
),
|
||||
['enter_long', 'enter_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
self.enable_short &
|
||||
(dataframe['bsp_state'].shift(1) == 1) &
|
||||
(dataframe['bsp_risk_ratio'].shift(1) > 0) &
|
||||
(short_space_ratio >= dataframe['bsp_risk_ratio'].shift(1) * short_min_sr_distance_r) &
|
||||
(dataframe['macdhist'].shift(1) < 0) &
|
||||
(dataframe['atr_ratio'].shift(1) >= min_atr_ratio) &
|
||||
(dataframe['trend'].shift(1) == -1) &
|
||||
short_tf_aligned
|
||||
),
|
||||
['enter_short', 'enter_tag']] = (1, 'short_signal_chan')
|
||||
return dataframe
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe['exit_long'] = 0
|
||||
dataframe['exit_short'] = 0
|
||||
return dataframe
|
||||
def get_trade_risk_ratio(self, pair: str, trade) -> float:
|
||||
risk_ratio = trade.get_custom_data('risk_ratio')
|
||||
if risk_ratio:
|
||||
return float(risk_ratio)
|
||||
|
||||
risk_ratio = 0.001
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if len(dataframe) > 0:
|
||||
entry_rows = dataframe[dataframe['date'] <= trade.open_date_utc]
|
||||
entry_candle = entry_rows.iloc[-1] if len(entry_rows) > 0 else dataframe.iloc[-1]
|
||||
signal_rows = entry_rows.tail(3)
|
||||
signal_rows = signal_rows[signal_rows['bsp_risk_ratio'] > 0]
|
||||
if len(signal_rows) > 0:
|
||||
signal_candle = signal_rows.iloc[-1]
|
||||
risk_ratio = float(signal_candle['bsp_risk_ratio'])
|
||||
trade.set_custom_data('bsp_stop_price', float(signal_candle['bsp_stop_price']))
|
||||
trade.set_custom_data('bsp_zg', float(signal_candle['bsp_zg']))
|
||||
trade.set_custom_data('bsp_zd', float(signal_candle['bsp_zd']))
|
||||
else:
|
||||
risk_ratio = max(0.001, min(float(entry_candle['atr_ratio']), 0.005))
|
||||
except Exception:
|
||||
risk_ratio = 0.001
|
||||
|
||||
trade.set_custom_data('risk_ratio', risk_ratio)
|
||||
return risk_ratio
|
||||
def adjust_trade_position(self, trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float,
|
||||
min_stake: float | None, max_stake: float,
|
||||
current_entry_rate: float, current_exit_rate: float,
|
||||
current_entry_profit: float, current_exit_profit: float,
|
||||
**kwargs):
|
||||
risk_ratio = self.get_trade_risk_ratio(trade.pair, trade)
|
||||
if current_profit >= risk_ratio and trade.nr_of_successful_exits == 0:
|
||||
return -(trade.stake_amount / 2), 'take_half_1r'
|
||||
return None
|
||||
def custom_exit(self, pair: str, trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, **kwargs):
|
||||
risk_ratio = self.get_trade_risk_ratio(pair, trade)
|
||||
if trade.nr_of_successful_exits > 0 and current_profit <= 0.001:
|
||||
return 'breakeven_after_1r'
|
||||
if current_profit >= risk_ratio * 2:
|
||||
return 'take_profit_2r'
|
||||
return None
|
||||
def custom_stoploss(self, pair: str, trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, after_fill: bool, **kwargs) -> float | None:
|
||||
bsp_stop_price = trade.get_custom_data('bsp_stop_price')
|
||||
if bsp_stop_price:
|
||||
sl = stoploss_from_absolute(float(bsp_stop_price), current_rate, is_short=trade.is_short)
|
||||
return min(sl, -0.05)
|
||||
return -0.05
|
||||
def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str,
|
||||
**kwargs) -> float:
|
||||
return self.lev
|
||||
def get_ticker_indicator(self):
|
||||
return int(self.timeframe[:-1])
|
||||
@@ -1,201 +0,0 @@
|
||||
# --- Do not remove these libs ---
|
||||
from statistics import median
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_absolute
|
||||
import sys
|
||||
import os
|
||||
# 添加父目录到系统路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ChanLun import ChanLun
|
||||
from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE
|
||||
# --------------------------------
|
||||
from technical.util import resample_to_interval, resampled_merge
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ChanLun_BTC_5m(IStrategy):
|
||||
"""ChanLun_BTC_5m: 5m B3 signals with trailing stop exit."""
|
||||
|
||||
INTERFACE_VERSION: int = 3
|
||||
timeframe = '5m'
|
||||
minimal_roi = {"0": 100}
|
||||
|
||||
can_short = True
|
||||
enable_long = True
|
||||
enable_short = False
|
||||
lev = 1.0
|
||||
stoploss = -0.3
|
||||
use_custom_stoploss = True
|
||||
|
||||
trailing_stop = False
|
||||
use_exit_signal = True
|
||||
position_adjustment_enable = False
|
||||
startup_candle_count = 500
|
||||
chan = ChanLun()
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe = self.add_indicators(dataframe)
|
||||
bsp_signal_data = self.chan.get_bsp_signal_data(dataframe)
|
||||
for column, values in bsp_signal_data.items():
|
||||
dataframe[column] = values
|
||||
return dataframe
|
||||
|
||||
def add_indicators(self, df):
|
||||
df = self.add_base_indicators(df)
|
||||
base_interval = self.get_ticker_indicator()
|
||||
for interval in (5, 15, 60):
|
||||
if interval <= base_interval:
|
||||
df = self.copy_base_indicators_to_resample(df, interval)
|
||||
continue
|
||||
resampled = resample_to_interval(df, interval)
|
||||
resampled = self.add_base_indicators(resampled)
|
||||
df = resampled_merge(df, resampled)
|
||||
return df
|
||||
|
||||
def copy_base_indicators_to_resample(self, df, interval):
|
||||
prefix = f'resample_{interval}_'
|
||||
for column in (
|
||||
'date', 'open', 'high', 'low', 'close', 'volume',
|
||||
'atr', 'macd', 'macdsignal', 'macdhist', 'ema24', 'ema52',
|
||||
'atr_ratio', 'resistance_240', 'support_240', 'trend'
|
||||
):
|
||||
if column in df.columns:
|
||||
df[f'{prefix}{column}'] = df[column]
|
||||
return df
|
||||
|
||||
def add_base_indicators(self, df):
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||
df['macd'] = macd['macd']
|
||||
df['macdsignal'] = macd['macdsignal']
|
||||
df['macdhist'] = macd['macdhist']
|
||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
||||
df['atr_ratio'] = df['atr'] / df['close']
|
||||
df['resistance_240'] = df['high'].rolling(240).max().shift(1)
|
||||
df['support_240'] = df['low'].rolling(240).min().shift(1)
|
||||
df['trend'] = 0
|
||||
df.loc[(df['close'] > df['ema52']) & (df['ema24'] >= df['ema52']), 'trend'] = 1
|
||||
df.loc[(df['close'] < df['ema52']) & (df['ema24'] <= df['ema52']), 'trend'] = -1
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
min_atr_ratio = 0.0005
|
||||
long_min_sr_distance_r = 1.0
|
||||
short_min_sr_distance_r = 0.8
|
||||
long_space_ratio = (dataframe['resistance_240'].shift(1) - dataframe['close'].shift(1)) / dataframe['close'].shift(1)
|
||||
short_space_ratio = (dataframe['close'].shift(1) - dataframe['support_240'].shift(1)) / dataframe['close'].shift(1)
|
||||
long_tf_aligned = (
|
||||
(dataframe['resample_5_trend'].shift(1) == 1).astype(int) +
|
||||
(dataframe['resample_15_trend'].shift(1) == 1).astype(int) +
|
||||
(dataframe['resample_60_trend'].shift(1) == 1).astype(int)
|
||||
) >= 2
|
||||
short_tf_aligned = (
|
||||
(dataframe['resample_5_trend'].shift(1) == -1).astype(int) +
|
||||
(dataframe['resample_15_trend'].shift(1) == -1).astype(int) +
|
||||
(dataframe['resample_60_trend'].shift(1) == -1).astype(int)
|
||||
) >= 2
|
||||
dataframe.loc[
|
||||
(
|
||||
self.enable_long &
|
||||
(dataframe['bsp_state'].shift(1) == -1) &
|
||||
(dataframe['bsp_risk_ratio'].shift(1) > 0) &
|
||||
(long_space_ratio >= dataframe['bsp_risk_ratio'].shift(1) * long_min_sr_distance_r) &
|
||||
(dataframe['macdhist'].shift(1) > 0) &
|
||||
(dataframe['atr_ratio'].shift(1) >= min_atr_ratio) &
|
||||
(dataframe['trend'].shift(1) == 1) &
|
||||
long_tf_aligned
|
||||
),
|
||||
['enter_long', 'enter_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
self.enable_short &
|
||||
(dataframe['bsp_state'].shift(1) == 1) &
|
||||
(dataframe['bsp_risk_ratio'].shift(1) > 0) &
|
||||
(short_space_ratio >= dataframe['bsp_risk_ratio'].shift(1) * short_min_sr_distance_r) &
|
||||
(dataframe['macdhist'].shift(1) < 0) &
|
||||
(dataframe['atr_ratio'].shift(1) >= min_atr_ratio) &
|
||||
(dataframe['trend'].shift(1) == -1) &
|
||||
short_tf_aligned
|
||||
),
|
||||
['enter_short', 'enter_tag']] = (1, 'short_signal_chan')
|
||||
return dataframe
|
||||
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe['exit_long'] = 0
|
||||
dataframe['exit_short'] = 0
|
||||
return dataframe
|
||||
|
||||
def custom_exit(self, pair: str, trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, **kwargs):
|
||||
# Time-based exit only - trailing stop handles profit taking
|
||||
elapsed = current_time - trade.open_date_utc
|
||||
if elapsed >= timedelta(hours=72) and current_profit < 0.005:
|
||||
return 'time_stop_72h'
|
||||
return None
|
||||
|
||||
def custom_stoploss(self, pair: str, trade, current_time: datetime, current_rate: float,
|
||||
current_profit: float, after_fill: bool, **kwargs) -> float | None:
|
||||
# Initialize stored state
|
||||
if not trade.get_custom_data('trail_activated'):
|
||||
trade.set_custom_data('trail_activated', False)
|
||||
trade.set_custom_data('max_profit', 0.0)
|
||||
# Read bsp_stop_price from signal
|
||||
try:
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
|
||||
if len(dataframe) > 0:
|
||||
entry_rows = dataframe[dataframe['date'] <= trade.open_date_utc]
|
||||
signal_rows = entry_rows.tail(3)
|
||||
signal_rows = signal_rows[signal_rows['bsp_risk_ratio'] > 0]
|
||||
if len(signal_rows) > 0:
|
||||
signal_candle = signal_rows.iloc[-1]
|
||||
trade.set_custom_data('bsp_stop_price', float(signal_candle['bsp_stop_price']))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_profit = max(float(trade.get_custom_data('max_profit')), current_profit)
|
||||
trade.set_custom_data('max_profit', max_profit)
|
||||
trail_activated = trade.get_custom_data('trail_activated')
|
||||
|
||||
# Stage 1: Initial stop at bsp_stop with -5% floor
|
||||
if not trail_activated:
|
||||
if max_profit >= 0.02:
|
||||
# Activate trail: move stop to breakeven
|
||||
trade.set_custom_data('trail_activated', True)
|
||||
sl = stoploss_from_absolute(trade.open_rate, current_rate, is_short=trade.is_short)
|
||||
return max(sl, -0.005)
|
||||
else:
|
||||
bsp_stop = trade.get_custom_data('bsp_stop_price')
|
||||
if bsp_stop:
|
||||
sl = stoploss_from_absolute(float(bsp_stop), current_rate, is_short=trade.is_short)
|
||||
return min(sl, -0.05)
|
||||
return -0.05
|
||||
else:
|
||||
# Stage 2: Trail from max profit
|
||||
if max_profit >= 0.04:
|
||||
trail_offset = 0.02 # Trail 2% behind max
|
||||
trail_price = trade.open_rate * (1 + max_profit - trail_offset)
|
||||
sl = stoploss_from_absolute(trail_price, current_rate, is_short=trade.is_short)
|
||||
return max(sl, -0.02)
|
||||
elif max_profit >= 0.02:
|
||||
# Breakeven to 1% trail
|
||||
sl = stoploss_from_absolute(trade.open_rate * 1.005, current_rate, is_short=trade.is_short)
|
||||
return max(sl, -0.005)
|
||||
else:
|
||||
sl = stoploss_from_absolute(trade.open_rate * 0.998, current_rate, is_short=trade.is_short)
|
||||
return max(sl, -0.02)
|
||||
|
||||
def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str,
|
||||
**kwargs) -> float:
|
||||
return self.lev
|
||||
|
||||
def get_ticker_indicator(self):
|
||||
return int(self.timeframe[:-1])
|
||||
@@ -1,133 +0,0 @@
|
||||
# --- Do not remove these libs ---
|
||||
from statistics import median
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_absolute
|
||||
import sys
|
||||
import os
|
||||
# 添加父目录到系统路径
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from ChanLun import ChanLun
|
||||
from ChanEnum import Chan_FX_TYPE, Chan_KLC_FX, Chan_BI_DIR, Chan_KLC_FX, Chan_BSP_TYPE
|
||||
# --------------------------------
|
||||
from technical.util import resample_to_interval, resampled_merge
|
||||
import talib.abstract as ta
|
||||
from pandas import DataFrame
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
### Now you can use logger.info('asfd') to log
|
||||
# freqtrade plot-dataframe --strategy ChanLun_BTC_1m --datadir user_data/data/binance -c ./user_data/ChanLun_SOL_30.json --timerange=20250309-
|
||||
|
||||
# freqtrade trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
|
||||
# freqtrade backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20260501-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json -t 1m 1m 1h 1d 1M --pairs BTC/USDT:USDT --timerange=20250405-
|
||||
# freqtrade download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json -t 1m 1h 1d 1M --pairs BTC/USDT --timerange=20170101-
|
||||
# freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --spaces roi --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies -c ./user_data/Chan/config/ChanLun_BTC_1m.json -e 200 --timerange=20250201-20250901
|
||||
# freqtrade edge -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange 20250721-20250901
|
||||
# freqtrade plot-dataframe -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange 20250721-20250901
|
||||
|
||||
# sudo docker compose run --rm chanlun_btc backtesting -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies --timerange=20250721-
|
||||
# sudo docker compose run --rm chanlun_btc download-data -c ./user_data/Chan/config/ChanLun_BTC_1m.json --pairs BTC/USDT:USDT -t 1m --timerange 20240101-
|
||||
# sudo docker compose run --rm chanlun_btc trade -c ./user_data/Chan/config/ChanLun_BTC_1m.json --strategy ChanLun_BTC_1m --strategy-path ./user_data/Chan/strategies
|
||||
|
||||
class Template(IStrategy):
|
||||
"""
|
||||
交易核心(缠论):
|
||||
- 仅在缠论一/二/三类买卖点出现时交易。
|
||||
- 信号触发条件:前一笔被确认(bi.is_sure)时,该笔 end_klc 已被标记为 B1/B2/B3 或 S1/S2/S3。
|
||||
- 不使用未确认笔,不使用“状态猜测”列。
|
||||
"""
|
||||
INTERFACE_VERSION: int = 3
|
||||
# Minimal ROI designed for the strategy.
|
||||
# This attribute will be overridden if the config file contains "minimal_roi"
|
||||
# 30m and 1h
|
||||
|
||||
minimal_roi = {
|
||||
"0": 0.05,
|
||||
"60": 0.03,
|
||||
"120": 0.01,
|
||||
"180": 0
|
||||
}
|
||||
# 5m and 15m
|
||||
minimal_roi_1 = {
|
||||
"0": 0.1,
|
||||
"60": 0.05,
|
||||
"120": 0.02,
|
||||
"240": 0
|
||||
}
|
||||
# 15m and 30m
|
||||
minimal_roi_1 = {
|
||||
"0": 0.1,
|
||||
"240": 0.05,
|
||||
"480": 0.03,
|
||||
"600": 0
|
||||
}
|
||||
minimal_roi_1 = {
|
||||
"0": 1.50,
|
||||
"120": 0.05,
|
||||
"240": 0.025,
|
||||
"360": 0
|
||||
}
|
||||
|
||||
can_short = True
|
||||
lev = 1.0
|
||||
stoploss = -0.3 # 设置为很大的负值,让custom_stoploss来控制
|
||||
|
||||
trailing_stop = False
|
||||
trailing_stop_positive = 0.03
|
||||
trailing_stop_positive_offset = 0.06
|
||||
trailing_only_offset_is_reached = False
|
||||
|
||||
# 关闭分批止盈/仓位调整
|
||||
startup_candle_count = 500
|
||||
# 以 1m 为基础周期时,1h = 60 根K线(用于读取 resample_60_* 列并做确认延迟)
|
||||
chan = ChanLun()
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe = self.add_indicators(dataframe)
|
||||
dataframe['bsp_state'] = self.chan.get_bsp_state(dataframe)
|
||||
return dataframe
|
||||
def add_indicators(self, df):
|
||||
fast = 12
|
||||
slow = 26
|
||||
period = 9
|
||||
macd = ta.MACD(df, fastperiod=fast, slowperiod=slow, signalperiod=period)
|
||||
df['atr'] = ta.ATR(df, timeperiod=14)
|
||||
df['macd'] = macd['macd']
|
||||
df['macdsignal'] = macd['macdsignal']
|
||||
df['macdhist'] = macd['macdhist']
|
||||
df['ema24'] = ta.EMA(df, timeperiod=24)
|
||||
df['ema52'] = ta.EMA(df, timeperiod=52)
|
||||
return df
|
||||
|
||||
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == -1)
|
||||
),
|
||||
['enter_long', 'enter_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == 1)
|
||||
),
|
||||
['enter_short', 'enter_tag']] = (1, 'short_signal_chan')
|
||||
return dataframe
|
||||
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
# 出场和进场共用同一套“确认笔 + end_klc 买卖点”语义。
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == 1)
|
||||
),
|
||||
['exit_long', 'exit_tag']] = (1, 'long_signal_chan')
|
||||
dataframe.loc[
|
||||
(
|
||||
(dataframe['bsp_state'].shift(1) == -1)
|
||||
),
|
||||
['exit_short', 'exit_tag']] = (1, 'short_signal_chan')
|
||||
return dataframe
|
||||
def leverage(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_leverage: float, max_leverage: float, entry_tag: Optional[str], side: str,
|
||||
**kwargs) -> float:
|
||||
return self.lev
|
||||
def get_ticker_indicator(self):
|
||||
return int(self.timeframe[:-1])
|
||||
@@ -1,272 +0,0 @@
|
||||
"""
|
||||
Phase 2: Run ChanPivotClassifier on real data, compute bi_out for each pivot,
|
||||
export the dataset, and run single-variable statistics.
|
||||
|
||||
Usage: python test_classifier.py
|
||||
"""
|
||||
|
||||
import csv
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Ensure Chan module is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from TF_DF import TF_DF
|
||||
from ChanPivotClassifier import ChanPivotClassifier
|
||||
from ChanEnum import Chan_BI_DIR
|
||||
|
||||
# Monkey-patch: TF_DF.init_TF_DF calls self.get_zs_list() which was removed.
|
||||
# Add it back as an alias for get_bi_zs_list.
|
||||
if not hasattr(TF_DF, 'get_zs_list'):
|
||||
TF_DF.get_zs_list = lambda self, bi_list, seg_list: self.get_bi_zs_list(bi_list)
|
||||
|
||||
|
||||
def load_csv(path: str) -> list[dict]:
|
||||
"""Load OHLCV CSV into list of dicts expected by TF_DF."""
|
||||
import pandas as pd
|
||||
df = pd.read_csv(path)
|
||||
df.columns = [c.lower() for c in df.columns]
|
||||
# TF_DF expects 'date' column
|
||||
if 'timestamp' in df.columns:
|
||||
df.rename(columns={'timestamp': 'date'}, inplace=True)
|
||||
df['date'] = pd.to_datetime(df['date'])
|
||||
return df
|
||||
|
||||
|
||||
def compute_bi_out(zs, bi_list: list) -> object:
|
||||
"""
|
||||
Determine the first bi after the pivot's end_bi that breaks out of the pivot range.
|
||||
A breakout is: bi.high > zs.gg (up) or bi.low < zs.dd (down).
|
||||
"""
|
||||
if zs.end_bi is None or not zs.is_sure:
|
||||
return None
|
||||
|
||||
# Find end_bi position in bi_list
|
||||
end_idx = None
|
||||
for i, bi in enumerate(bi_list):
|
||||
if bi is zs.end_bi or bi.index == zs.end_bi.index:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx is None:
|
||||
return None
|
||||
|
||||
# Look for the first bi after end_bi that breaks the pivot range
|
||||
for i in range(end_idx + 1, len(bi_list)):
|
||||
bi = bi_list[i]
|
||||
if not bi.is_sure:
|
||||
continue
|
||||
# A breakout: goes above gg or below dd
|
||||
if bi.high > zs.gg or bi.low < zs.dd:
|
||||
return bi
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_pipeline(csv_path: str, symbol: str, timeframe: str, interval: int = 1):
|
||||
"""Full pipeline: CSV → TF_DF → compute bi_out → ChanPivotClassifier."""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Processing: {symbol} {timeframe}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Step 1: Load data
|
||||
df = load_csv(csv_path)
|
||||
print(f"Loaded {len(df)} rows")
|
||||
|
||||
# Step 2: Run TF_DF pipeline
|
||||
tf_df = TF_DF(df, interval, timeframe)
|
||||
print(f"KLC count: {len(tf_df.klc_list)}")
|
||||
print(f"BI count: {len(tf_df.bi_list)}")
|
||||
|
||||
# Get bi_zs_list via the seg-based method (matching find_all_bsp)
|
||||
bi_zs_list = tf_df.cal_bi_zs(tf_df.seg_list)
|
||||
print(f"Pivot count (raw): {len(bi_zs_list)}")
|
||||
|
||||
# Filter to sure pivots with enough internal strokes
|
||||
sure_pivots = [zs for zs in bi_zs_list if zs.is_sure and len(zs.bi_list) >= 3]
|
||||
print(f"Pivot count (sure, >=3 strokes): {len(sure_pivots)}")
|
||||
|
||||
# Step 3: Compute bi_out for each pivot
|
||||
for zs in sure_pivots:
|
||||
zs.bi_out = compute_bi_out(zs, tf_df.bi_list)
|
||||
|
||||
bi_out_count = sum(1 for zs in sure_pivots if zs.bi_out is not None)
|
||||
print(f"Pivots with bi_out: {bi_out_count}/{len(sure_pivots)}")
|
||||
|
||||
# Step 4: Run ChanPivotClassifier
|
||||
classifier = ChanPivotClassifier(sure_pivots, symbol=symbol, timeframe=timeframe)
|
||||
dataset = classifier.extract()
|
||||
print(f"Dataset samples: {len(dataset)}")
|
||||
|
||||
# Step 5: Export
|
||||
output_path = f"/tmp/chan_dataset_{symbol.replace('/', '_')}_{timeframe}.json"
|
||||
count = classifier.export_json(output_path)
|
||||
print(f"Exported {count} samples to {output_path}")
|
||||
|
||||
return dataset
|
||||
|
||||
|
||||
def run_statistics(dataset: list[dict]):
|
||||
"""Phase 2 statistics: single-variable analysis."""
|
||||
print(f"\n{'='*60}")
|
||||
print("Phase 2 — Single-Variable Statistics")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
if not dataset:
|
||||
print("No data to analyze.")
|
||||
return
|
||||
|
||||
total = len(dataset)
|
||||
up = [d for d in dataset if d["label"] == "up"]
|
||||
down = [d for d in dataset if d["label"] == "down"]
|
||||
none_ = [d for d in dataset if d["label"] == "none"]
|
||||
|
||||
print(f"Total samples: {total}")
|
||||
print(f" Up: {len(up)} ({len(up)/total*100:.1f}%)")
|
||||
print(f" Down: {len(down)} ({len(down)/total*100:.1f}%)")
|
||||
print(f" None: {len(none_)} ({len(none_)/total*100:.1f}%)")
|
||||
|
||||
# ================================================================
|
||||
# Feature 1: contraction vs break direction
|
||||
# ================================================================
|
||||
print(f"\n--- Feature: contraction (convergence rate) ---")
|
||||
for label, subset in [("up", up), ("down", down), ("none", none_)]:
|
||||
if not subset:
|
||||
continue
|
||||
contractions = [d["contraction"] for d in subset]
|
||||
avg = sum(contractions) / len(contractions)
|
||||
print(f" {label}: mean contraction = {avg:.4f}")
|
||||
|
||||
# Contraction < 0.7 → P(up)?
|
||||
high_contraction = [d for d in dataset if d["contraction"] < 0.7]
|
||||
if high_contraction:
|
||||
up_in_hc = len([d for d in high_contraction if d["label"] == "up"])
|
||||
down_in_hc = len([d for d in high_contraction if d["label"] == "down"])
|
||||
print(f"\n Contraction < 0.7 (converging): {len(high_contraction)} samples")
|
||||
print(f" P(up) = {up_in_hc/len(high_contraction)*100:.1f}%")
|
||||
print(f" P(down) = {down_in_hc/len(high_contraction)*100:.1f}%")
|
||||
|
||||
# Contraction > 1.2 → P(down)?
|
||||
low_contraction = [d for d in dataset if d["contraction"] > 1.2]
|
||||
if low_contraction:
|
||||
up_in_lc = len([d for d in low_contraction if d["label"] == "up"])
|
||||
down_in_lc = len([d for d in low_contraction if d["label"] == "down"])
|
||||
print(f"\n Contraction > 1.2 (expanding): {len(low_contraction)} samples")
|
||||
print(f" P(up) = {up_in_lc/len(low_contraction)*100:.1f}%")
|
||||
print(f" P(down) = {down_in_lc/len(low_contraction)*100:.1f}%")
|
||||
|
||||
# ================================================================
|
||||
# Feature 2: shift_norm vs break direction
|
||||
# ================================================================
|
||||
print(f"\n--- Feature: shift_norm (center drift) ---")
|
||||
for label, subset in [("up", up), ("down", down), ("none", none_)]:
|
||||
if not subset:
|
||||
continue
|
||||
shifts = [d["shift_norm"] for d in subset]
|
||||
avg = sum(shifts) / len(shifts)
|
||||
print(f" {label}: mean shift_norm = {avg:.4f}")
|
||||
|
||||
# shift > 0 → P(up)?
|
||||
shift_up = [d for d in dataset if d["shift_norm"] > 0]
|
||||
if shift_up:
|
||||
up_in_su = len([d for d in shift_up if d["label"] == "up"])
|
||||
down_in_su = len([d for d in shift_up if d["label"] == "down"])
|
||||
print(f"\n shift_norm > 0 (drifting up): {len(shift_up)} samples")
|
||||
print(f" P(up) = {up_in_su/len(shift_up)*100:.1f}%")
|
||||
print(f" P(down) = {down_in_su/len(shift_up)*100:.1f}%")
|
||||
|
||||
# shift < 0 → P(down)?
|
||||
shift_down = [d for d in dataset if d["shift_norm"] < 0]
|
||||
if shift_down:
|
||||
up_in_sd = len([d for d in shift_down if d["label"] == "up"])
|
||||
down_in_sd = len([d for d in shift_down if d["label"] == "down"])
|
||||
print(f"\n shift_norm < 0 (drifting down): {len(shift_down)} samples")
|
||||
print(f" P(up) = {up_in_sd/len(shift_down)*100:.1f}%")
|
||||
print(f" P(down) = {down_in_sd/len(shift_down)*100:.1f}%")
|
||||
|
||||
# ================================================================
|
||||
# Feature 3: duration_norm vs break direction
|
||||
# ================================================================
|
||||
print(f"\n--- Feature: duration_norm (relative duration) ---")
|
||||
for label, subset in [("up", up), ("down", down), ("none", none_)]:
|
||||
if not subset:
|
||||
continue
|
||||
durations = [d["duration_norm"] for d in subset]
|
||||
avg = sum(durations) / len(durations)
|
||||
print(f" {label}: mean duration_norm = {avg:.4f}")
|
||||
|
||||
# ================================================================
|
||||
# Combined: contraction < 0.7 AND shift_norm > 0 → P(up)?
|
||||
# ================================================================
|
||||
print(f"\n--- Combined signals ---")
|
||||
converging_up = [d for d in dataset if d["contraction"] < 0.7 and d["shift_norm"] > 0]
|
||||
if converging_up:
|
||||
up_in_cu = len([d for d in converging_up if d["label"] == "up"])
|
||||
down_in_cu = len([d for d in converging_up if d["label"] == "down"])
|
||||
print(f" Contraction < 0.7 AND shift_norm > 0: {len(converging_up)} samples")
|
||||
print(f" P(up) = {up_in_cu/len(converging_up)*100:.1f}%")
|
||||
print(f" P(down) = {down_in_cu/len(converging_up)*100:.1f}%")
|
||||
|
||||
converging_down = [d for d in dataset if d["contraction"] < 0.7 and d["shift_norm"] < 0]
|
||||
if converging_down:
|
||||
up_in_cd = len([d for d in converging_down if d["label"] == "up"])
|
||||
down_in_cd = len([d for d in converging_down if d["label"] == "down"])
|
||||
print(f" Contraction < 0.7 AND shift_norm < 0: {len(converging_down)} samples")
|
||||
print(f" P(up) = {up_in_cd/len(converging_down)*100:.1f}%")
|
||||
print(f" P(down) = {down_in_cd/len(converging_down)*100:.1f}%")
|
||||
|
||||
return dataset
|
||||
|
||||
|
||||
def extract_symbol(csv_name: str) -> str:
|
||||
"""Extract symbol from filename like 'BTC_USDT_1d.csv'."""
|
||||
parts = csv_name.replace(".csv", "").split("_")
|
||||
if len(parts) >= 2:
|
||||
return f"{parts[0]}/{parts[1]}"
|
||||
return csv_name
|
||||
|
||||
|
||||
def extract_timeframe(csv_name: str) -> str:
|
||||
"""Extract timeframe from filename like 'BTC_USDT_1d.csv'."""
|
||||
parts = csv_name.replace(".csv", "").split("_")
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return "1d"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import glob
|
||||
import json
|
||||
|
||||
data_dir = "/Users/jack/Project/freqtrade/binance_data"
|
||||
csv_files = sorted(glob.glob(f"{data_dir}/*_USDT_1h.csv"))
|
||||
|
||||
if not csv_files:
|
||||
print("No data files found.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(csv_files)} data files:")
|
||||
for f in csv_files:
|
||||
print(f" {os.path.basename(f)}")
|
||||
|
||||
# Batch process all coins
|
||||
all_data = []
|
||||
for csv_path in csv_files:
|
||||
basename = os.path.basename(csv_path)
|
||||
symbol = extract_symbol(basename)
|
||||
timeframe = extract_timeframe(basename)
|
||||
try:
|
||||
dataset = run_pipeline(csv_path, symbol, timeframe)
|
||||
all_data.extend(dataset)
|
||||
except Exception as e:
|
||||
print(f" ERROR: {symbol} — {e}")
|
||||
|
||||
# Export combined dataset
|
||||
combined_path = "/tmp/chan_dataset_all_coins.json"
|
||||
with open(combined_path, "w", encoding="utf-8") as f:
|
||||
json.dump(all_data, f, indent=2, ensure_ascii=False, default=str)
|
||||
print(f"\nCombined dataset: {len(all_data)} samples → {combined_path}")
|
||||
|
||||
# Run statistics on combined dataset
|
||||
run_statistics(all_data)
|
||||
@@ -1,300 +0,0 @@
|
||||
"""
|
||||
StructureZone 系统单元测试
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
from ChanZone import (
|
||||
RawZonePoint, StructureZone, StructureZoneConfig,
|
||||
cluster_raw_points, build_structure_zones, _calc_strength, _calc_confidence, _calc_recency,
|
||||
extract_raw_points_from_serialized, analyze_structure_zones_from_serialized,
|
||||
)
|
||||
|
||||
|
||||
class TestClusterRawPoints:
|
||||
"""聚类算法测试"""
|
||||
|
||||
def test_empty_points(self):
|
||||
config = StructureZoneConfig()
|
||||
result = cluster_raw_points([], config)
|
||||
assert result == []
|
||||
|
||||
def test_single_point_filtered(self):
|
||||
"""单点被 min_overlap 过滤"""
|
||||
config = StructureZoneConfig(min_overlap_for_zone=2)
|
||||
points = [RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu',
|
||||
boundary_type='ZG', source_zs_id=0, is_sure=True)]
|
||||
result = cluster_raw_points(points, config)
|
||||
assert result == []
|
||||
|
||||
def test_two_nearby_points_merge(self):
|
||||
"""相邻价格点归为一类"""
|
||||
config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2)
|
||||
points = [
|
||||
RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu',
|
||||
boundary_type='ZG', source_zs_id=0, is_sure=True),
|
||||
RawZonePoint(price=100.5, timeframe='15m', structure_type='xd_zhongshu',
|
||||
boundary_type='ZD', source_zs_id=0, is_sure=True),
|
||||
]
|
||||
result = cluster_raw_points(points, config)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 2
|
||||
|
||||
def test_two_distant_points_separate(self):
|
||||
"""远离的价格点不归为一类"""
|
||||
config = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=1) # 先用 1 看聚类
|
||||
points = [
|
||||
RawZonePoint(price=100, timeframe='5m', structure_type='bi_zhongshu',
|
||||
boundary_type='ZG', source_zs_id=0, is_sure=True),
|
||||
RawZonePoint(price=110, timeframe='15m', structure_type='xd_zhongshu',
|
||||
boundary_type='ZD', source_zs_id=0, is_sure=True),
|
||||
]
|
||||
# 先用 min_overlap=2 确认被过滤
|
||||
config2 = StructureZoneConfig(cluster_radius_pct=0.1, min_overlap_for_zone=2)
|
||||
result = cluster_raw_points(points, config2)
|
||||
assert result == [] # 两个单独点,都不够 min_overlap
|
||||
|
||||
def test_multi_tf_convergence(self):
|
||||
"""多个时间周期在相同价格区间聚合"""
|
||||
config = StructureZoneConfig(cluster_radius_pct=1.0, min_overlap_for_zone=2)
|
||||
points = []
|
||||
for tf in ['5m', '15m', '30m', '1h']:
|
||||
for btype in ['ZG', 'ZD']:
|
||||
points.append(RawZonePoint(price=100 + abs(hash(tf + btype)) % 3 * 0.1,
|
||||
timeframe=tf, structure_type='bi_zhongshu',
|
||||
boundary_type=btype, source_zs_id=0, is_sure=True))
|
||||
result = cluster_raw_points(points, config)
|
||||
assert len(result) >= 1
|
||||
# 所有点应该聚合在一起(价差很小)
|
||||
total = sum(len(c) for c in result)
|
||||
assert total == len(points)
|
||||
|
||||
|
||||
class TestBuildStructureZones:
|
||||
"""评分和构建测试"""
|
||||
|
||||
def _make_cluster(self, prices, tf='5m', st='bi_zhongshu'):
|
||||
return [RawZonePoint(price=p, timeframe=tf, structure_type=st,
|
||||
boundary_type='ZG', source_zs_id=0, is_sure=True,
|
||||
candle_time='2025-01-01T00:00:00')
|
||||
for p in prices]
|
||||
|
||||
def test_zone_type_support(self):
|
||||
"""当前价上方区间是阻力,下方是支撑"""
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([90, 92])
|
||||
ema52 = {'5m': 100}
|
||||
zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert len(zones) == 1
|
||||
assert zones[0].zone_type == 'support' # 在价格下方
|
||||
|
||||
def test_zone_type_resistance(self):
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([110, 112])
|
||||
ema52 = {'5m': 100}
|
||||
zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert len(zones) == 1
|
||||
assert zones[0].zone_type == 'resistance'
|
||||
|
||||
def test_zone_type_neutral(self):
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([95, 105])
|
||||
ema52 = {'5m': 100}
|
||||
zones = build_structure_zones([cluster], current_price=100, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert len(zones) == 1
|
||||
assert zones[0].zone_type == 'neutral'
|
||||
|
||||
def test_strength_score_range(self):
|
||||
"""评分在 0-100 之间"""
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([100, 102, 104], '5m', 'bi_zhongshu')
|
||||
cluster += self._make_cluster([100.5, 102.5], '15m', 'xd_zhongshu')
|
||||
ema52 = {'5m': 0, '15m': 0}
|
||||
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert len(zones) == 1
|
||||
assert 0 <= zones[0].strength_score <= 100
|
||||
|
||||
def test_ema52_aligned_true(self):
|
||||
"""EMA52 落在区间内"""
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([95, 105])
|
||||
ema52 = {'5m': 100}
|
||||
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert zones[0].ema52_aligned is True
|
||||
|
||||
def test_ema52_aligned_false(self):
|
||||
"""EMA52 不在区间内"""
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([95, 105])
|
||||
ema52 = {'5m': 120}
|
||||
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert zones[0].ema52_aligned is False
|
||||
|
||||
def test_confidence_range(self):
|
||||
"""置信度在 0-1 之间"""
|
||||
config = StructureZoneConfig()
|
||||
cluster = self._make_cluster([100, 101, 102, 103])
|
||||
ema52 = {}
|
||||
zones = build_structure_zones([cluster], current_price=110, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert 0 <= zones[0].confidence <= 1
|
||||
|
||||
def test_max_zones_cap(self):
|
||||
"""max_zones 限制返回数量"""
|
||||
config = StructureZoneConfig(max_zones=3)
|
||||
clusters = [self._make_cluster([100 + i * 10, 100 + i * 10 + 2]) for i in range(10)]
|
||||
ema52 = {}
|
||||
zones = build_structure_zones(clusters, current_price=150, ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert len(zones) <= 3
|
||||
|
||||
def test_sorted_by_strength(self):
|
||||
"""按 strength 降序排列"""
|
||||
config = StructureZoneConfig(max_zones=0)
|
||||
# 创建一个有更多重叠的聚类(更强)和一个较弱的聚类
|
||||
cluster_strong = self._make_cluster([100, 101, 102, 103, 104]) # 5 点
|
||||
cluster_weak = self._make_cluster([200, 201]) # 2 点
|
||||
ema52 = {'5m': 0}
|
||||
zones = build_structure_zones([cluster_weak, cluster_strong], current_price=150,
|
||||
ema52_values=ema52,
|
||||
latest_candle_time='2025-01-01T01:00:00', config=config)
|
||||
assert zones[0].strength_score >= zones[-1].strength_score
|
||||
|
||||
|
||||
class TestExtractFromSerialized:
|
||||
"""从序列化数据提取测试"""
|
||||
|
||||
def test_empty_analyses(self):
|
||||
config = StructureZoneConfig()
|
||||
points = extract_raw_points_from_serialized({}, {}, config)
|
||||
assert points == []
|
||||
|
||||
def test_basic_extraction(self):
|
||||
analyses = {
|
||||
'5m': {
|
||||
'bi_zs_list': [
|
||||
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'},
|
||||
],
|
||||
'zs_list': [],
|
||||
},
|
||||
'15m': {
|
||||
'bi_zs_list': [],
|
||||
'zs_list': [
|
||||
{'zg': 105, 'zd': 98, 'gg': 107, 'dd': 96, 'is_sure': True, 'end_time': '2025-01-01T00:00'},
|
||||
],
|
||||
},
|
||||
}
|
||||
ema52 = {'5m': 101, '15m': 103}
|
||||
config = StructureZoneConfig(zone_timeframes=['5m', '15m'])
|
||||
points = extract_raw_points_from_serialized(analyses, ema52, config)
|
||||
# bi_zs: 4 points (ZG/ZD/GG/DD) + zs_list: 4 points + 2 ema52 = 10
|
||||
assert len(points) == 10
|
||||
|
||||
def test_unsure_filtered(self):
|
||||
analyses = {
|
||||
'5m': {
|
||||
'bi_zs_list': [
|
||||
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': False},
|
||||
],
|
||||
'zs_list': [],
|
||||
},
|
||||
}
|
||||
ema52 = {}
|
||||
config = StructureZoneConfig(zone_timeframes=['5m'])
|
||||
points = extract_raw_points_from_serialized(analyses, ema52, config)
|
||||
assert len(points) == 0 # is_sure=False 被过滤
|
||||
|
||||
def test_timeframe_filtering(self):
|
||||
"""仅提取 config.zone_timeframes 中的周期"""
|
||||
analyses = {
|
||||
'5m': {
|
||||
'bi_zs_list': [
|
||||
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True},
|
||||
],
|
||||
'zs_list': [],
|
||||
},
|
||||
'1h': {
|
||||
'bi_zs_list': [
|
||||
{'zg': 200, 'zd': 190, 'gg': 205, 'dd': 188, 'is_sure': True},
|
||||
],
|
||||
'zs_list': [],
|
||||
},
|
||||
}
|
||||
ema52 = {'5m': 101, '1h': 195}
|
||||
config = StructureZoneConfig(zone_timeframes=['5m']) # 只取 5m
|
||||
points = extract_raw_points_from_serialized(analyses, ema52, config)
|
||||
# 只有 5m: 4 bi_zs + 1 ema52 = 5
|
||||
assert len(points) == 5
|
||||
assert all(p.timeframe == '5m' for p in points)
|
||||
|
||||
|
||||
class TestScoringHelpers:
|
||||
"""评分辅助函数测试"""
|
||||
|
||||
def test_calc_recency_same_time(self):
|
||||
"""同一时间的 recency = 1.0"""
|
||||
score = _calc_recency('2025-01-01T00:00:00', '2025-01-01T00:00:00', 50)
|
||||
assert score == 1.0
|
||||
|
||||
def test_calc_recency_invalid(self):
|
||||
"""无效时间的 recency = 0.5"""
|
||||
score = _calc_recency(None, '2025-01-01T00:00:00', 50)
|
||||
assert score == 0.5
|
||||
|
||||
def test_calc_confidence_high(self):
|
||||
"""高重叠数 = 高置信度"""
|
||||
conf = _calc_confidence(6, 3, [])
|
||||
assert conf > 0.7
|
||||
|
||||
def test_calc_confidence_low(self):
|
||||
"""低重叠数 = 低置信度"""
|
||||
conf = _calc_confidence(2, 1, [])
|
||||
assert conf < 0.7
|
||||
|
||||
|
||||
class TestAnalyzeFromSerialized:
|
||||
"""端到端测试(从序列化数据到 StructureZone)"""
|
||||
|
||||
def test_end_to_end(self):
|
||||
analyses = {
|
||||
'5m': {
|
||||
'bi_zs_list': [
|
||||
{'zg': 100, 'zd': 95, 'gg': 102, 'dd': 93, 'is_sure': True, 'end_time': '2025-01-01T00:00'},
|
||||
],
|
||||
'zs_list': [],
|
||||
},
|
||||
'15m': {
|
||||
'bi_zs_list': [
|
||||
{'zg': 101, 'zd': 96, 'gg': 103, 'dd': 94, 'is_sure': True, 'end_time': '2025-01-01T00:01'},
|
||||
],
|
||||
'zs_list': [],
|
||||
},
|
||||
}
|
||||
ema52_dict = {'5m': 100.5, '15m': 99.5}
|
||||
config = StructureZoneConfig(zone_timeframes=['5m', '15m'], cluster_radius_pct=2.0)
|
||||
zones = analyze_structure_zones_from_serialized(analyses, ema52_dict, 110, config)
|
||||
# 两个 TF 的 BI_ZS 价格接近,应聚合成一个区间
|
||||
assert len(zones) >= 1
|
||||
zone = zones[0]
|
||||
assert zone.zone_type == 'support' # 价格在 93-103,current_price=110
|
||||
assert '5m' in zone.timeframes
|
||||
assert '15m' in zone.timeframes
|
||||
assert zone.structure_types == ['bi_zhongshu']
|
||||
assert 0 <= zone.strength_score <= 100
|
||||
assert 0 <= zone.confidence <= 1
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
zones = analyze_structure_zones_from_serialized({}, {}, 100)
|
||||
assert zones == []
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
Vendored
BIN
Binary file not shown.
+34
-227
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, render_template, jsonify, request, send_from_directory
|
||||
from flask import Flask, render_template, jsonify, request
|
||||
from collections import OrderedDict
|
||||
import json
|
||||
import logging
|
||||
@@ -12,7 +12,6 @@ import io
|
||||
import base64
|
||||
import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pytz import timezone
|
||||
import talib.abstract as ta
|
||||
import numpy as np
|
||||
@@ -23,7 +22,6 @@ from ChanLun import ChanLun, TF_DF
|
||||
from ChanEnum import Chan_BI_DIR, Chan_SEG_DIR, Chan_KLC_FX, Chan_FX_TYPE, Chan_MACDSEG_DIR, Chan_MACDHISTSET_DIR
|
||||
from cn_stock_data import ChinaStockData
|
||||
from ChanMACD import ChanMACD
|
||||
from ChanZone import StructureZoneConfig, analyze_structure_zones_from_serialized
|
||||
|
||||
# 添加买卖点枚举类型
|
||||
class TRADE_POINT_TYPE:
|
||||
@@ -35,7 +33,7 @@ class TRADE_POINT_TYPE:
|
||||
SELL3 = -3 # 三类卖点
|
||||
|
||||
app = Flask(__name__)
|
||||
macd_factor = 1
|
||||
macd_factor = 2
|
||||
smooth_factor = 1
|
||||
macd_fast_period = 12 * macd_factor
|
||||
macd_slow_period = 26 * macd_factor
|
||||
@@ -43,35 +41,15 @@ macd_signal_period = 9 * smooth_factor
|
||||
# 初始化交易所
|
||||
exchange = ccxt.binance({
|
||||
'enableRateLimit': True,
|
||||
'proxies': {
|
||||
'http': 'http://127.0.0.1:7897',
|
||||
'https': 'http://127.0.0.1:7897',
|
||||
},
|
||||
})
|
||||
|
||||
# 初始化 A 股数据获取器(K 线优先请求 A-Share Data Platform,默认 http://103.179.242.166:8000 ,见 /api/v1/klines 文档;ASHARE_DP_URL 覆盖,置空则仅用 AKShare)
|
||||
# 初始化A股数据获取器
|
||||
china_stock = ChinaStockData()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 结构价值区缓存: {tf_name: {'data': ..., 'expires': timestamp}}
|
||||
_zone_cache = {}
|
||||
|
||||
def _zone_cache_ttl(tf_name: str) -> int:
|
||||
"""根据时间周期返回缓存过期时间(秒)"""
|
||||
minutes = timeframe_to_minutes(tf_name) or 5
|
||||
if minutes <= 5:
|
||||
return 120 # 5m及以下: 2分钟
|
||||
elif minutes <= 15:
|
||||
return 300 # 15m: 5分钟
|
||||
elif minutes <= 60:
|
||||
return 600 # 1h: 10分钟
|
||||
else:
|
||||
return 1800 # 4h+: 30分钟
|
||||
|
||||
# 加密货币本地/自建行情服务(与 A 股 ASHARE_DP_URL 端口可不同)
|
||||
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://103.179.242.166"))
|
||||
|
||||
DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://127.0.0.1:9009"))
|
||||
#DATA_SERVICE_URL = os.environ.get("DATA_SERVICE_URL", os.environ.get("DATASVC_URL", "http://192.168.1.9:9009"))
|
||||
DEFAULT_TIMEFRAME_LABELS = OrderedDict([
|
||||
("1m", "1分钟"),
|
||||
("3m", "3分钟"),
|
||||
@@ -155,40 +133,6 @@ def build_timeframe_labels(timeframes):
|
||||
return labels
|
||||
|
||||
|
||||
def compute_timeframe_defaults(labels_ordered):
|
||||
"""
|
||||
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
|
||||
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
|
||||
"""
|
||||
if not labels_ordered:
|
||||
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
timeframe_keys = list(labels_ordered.keys())
|
||||
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in labels_ordered), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in labels_ordered and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx = timeframe_keys.index(default_main)
|
||||
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_element = timeframe_keys[0]
|
||||
else:
|
||||
default_element = default_main
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx_el = timeframe_keys.index(default_element)
|
||||
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_sub_sub = timeframe_keys[0]
|
||||
else:
|
||||
default_sub_sub = default_element
|
||||
|
||||
return default_main, default_element, default_sub_sub, timeframe_keys
|
||||
|
||||
|
||||
def _parse_time_input(value):
|
||||
if value in (None, '', 0):
|
||||
return None
|
||||
@@ -239,8 +183,6 @@ def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=
|
||||
params["start"] = int(start_ms)
|
||||
if end_ms is not None:
|
||||
params["end"] = int(end_ms)
|
||||
if limit is not None:
|
||||
params["limit"] = limit
|
||||
resp = requests.get(f"{DATA_SERVICE_URL}/api/candles", params=params, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -269,8 +211,7 @@ def _fetch_kl_from_datasvc(symbol, timeframe, start_ms=None, end_ms=None, limit=
|
||||
refresh_data_service_metadata(force=True)
|
||||
|
||||
# A股热门股票
|
||||
# 模板中 A 股下拉仅放默认一项;用户切换到「A股」时由前端请求 /api/a_stocks 填充全市场(约 5500+)
|
||||
A_STOCK_SYMBOLS = [{'symbol': '000001', 'name': '平安银行'}]
|
||||
A_STOCK_SYMBOLS = china_stock.get_popular_stocks()
|
||||
|
||||
def detect_symbol_type(symbol):
|
||||
"""检测交易对类型:crypto 或 a_stock"""
|
||||
@@ -599,12 +540,11 @@ def analyze_chan(df, symbol=None, timeframe=None):
|
||||
zs_list = chan.calculate_seg_zs(seg_list)
|
||||
# 计算笔中枢(BI中枢)并拍平成列表
|
||||
|
||||
#bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
|
||||
#bi_zs_list = chan.cal_bi_zs_list(bi_list)
|
||||
bi_zs_list = chan.cal_bi_zs(seg_list)
|
||||
bsp_list = []
|
||||
if len(bi_zs_list) > 0:
|
||||
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
|
||||
#bsp_state_list = chan.get_bsp_state(df)
|
||||
#for bsp in bsp_list:
|
||||
#print(bsp.end_time, bsp.type, bsp.dir)
|
||||
# 添加买卖点识别
|
||||
@@ -1252,29 +1192,43 @@ def trend_detail():
|
||||
}
|
||||
})
|
||||
|
||||
@app.route('/chan_tv')
|
||||
def chan_tv():
|
||||
"""缠论 TradingView 高级图表页面"""
|
||||
return render_template('chan_tv.html')
|
||||
|
||||
@app.route('/charting_library/<path:filename>')
|
||||
def serve_charting_library(filename):
|
||||
"""提供 TradingView Charting Library 静态文件"""
|
||||
return send_from_directory('charting_library', filename)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""主页"""
|
||||
refresh_data_service_metadata()
|
||||
tf_map = TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy()
|
||||
default_main, default_element, default_sub_sub, timeframe_keys = compute_timeframe_defaults(OrderedDict(tf_map))
|
||||
timeframe_items = list(TIMEFRAMES.items())
|
||||
timeframe_keys = [item[0] for item in timeframe_items]
|
||||
symbols = SYMBOLS if SYMBOLS else DEFAULT_SYMBOLS
|
||||
|
||||
preferred_main = next((tf for tf in ['5m', '15m', '1h'] if tf in TIMEFRAMES), None)
|
||||
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
|
||||
if default_main not in TIMEFRAMES and timeframe_keys:
|
||||
default_main = timeframe_keys[0]
|
||||
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx = timeframe_keys.index(default_main)
|
||||
default_element = timeframe_keys[idx - 1] if idx > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_element = timeframe_keys[0]
|
||||
else:
|
||||
default_element = default_main
|
||||
|
||||
# 次次周期默认比次周期小一档
|
||||
if timeframe_keys:
|
||||
try:
|
||||
idx_el = timeframe_keys.index(default_element)
|
||||
default_sub_sub = timeframe_keys[idx_el - 1] if idx_el > 0 else timeframe_keys[0]
|
||||
except ValueError:
|
||||
default_sub_sub = timeframe_keys[0]
|
||||
else:
|
||||
default_sub_sub = default_element
|
||||
|
||||
default_symbol = 'BTC/USDT:USDT' if 'BTC/USDT:USDT' in symbols else (symbols[0] if symbols else '')
|
||||
|
||||
return render_template(
|
||||
'index.html',
|
||||
timeframes=tf_map,
|
||||
timeframes=TIMEFRAMES,
|
||||
symbols=symbols,
|
||||
a_stock_symbols=A_STOCK_SYMBOLS,
|
||||
default_main_timeframe=default_main,
|
||||
@@ -1285,39 +1239,6 @@ def index():
|
||||
data_service_available=DATA_SERVICE_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/api/chart_metadata')
|
||||
def api_chart_metadata():
|
||||
"""
|
||||
按数据源返回图表用 K 线周期(中文标签)及主/次/次次默认周期。
|
||||
crypto:强制刷新 DATA_SERVICE_URL /health 元信息;
|
||||
a_stock:读取 ASHARE_DP_URL 的 /api/v1/klines/available-freqs,不修改全局加密货币 TIMEFRAMES。
|
||||
"""
|
||||
source = (request.args.get('source') or 'crypto').strip().lower()
|
||||
if source not in ('crypto', 'a_stock'):
|
||||
source = 'crypto'
|
||||
try:
|
||||
if source == 'a_stock':
|
||||
raw = china_stock.get_available_kline_freqs()
|
||||
labels_od = build_timeframe_labels(raw)
|
||||
else:
|
||||
refresh_data_service_metadata(force=True)
|
||||
labels_od = OrderedDict(TIMEFRAMES if TIMEFRAMES else DEFAULT_TIMEFRAME_LABELS.copy())
|
||||
|
||||
default_main, default_element, default_sub_sub, keys = compute_timeframe_defaults(labels_od)
|
||||
return jsonify({
|
||||
'source': source,
|
||||
'timeframes': {k: v for k, v in labels_od.items()},
|
||||
'timeframe_keys': keys,
|
||||
'default_main': default_main,
|
||||
'default_element': default_element,
|
||||
'default_sub_sub': default_sub_sub,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.exception('chart_metadata 失败: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
|
||||
@app.route('/api/analyze')
|
||||
def analyze():
|
||||
"""分析接口"""
|
||||
@@ -1855,120 +1776,6 @@ def analyze():
|
||||
|
||||
pass
|
||||
|
||||
# 结构价值区分析(Structure Zone)—— 按需拉取:仅当 include_structure_zones 为真时执行多周期拉取(默认跳过以减轻负载)
|
||||
include_zones_param = request.args.get('include_structure_zones', '')
|
||||
include_structure_zones = str(include_zones_param).lower() in ('1', 'true', 'yes')
|
||||
if include_structure_zones:
|
||||
zone_timeframes_str = request.args.get('zone_timeframes', '')
|
||||
zone_kl_lines = int(request.args.get('zone_kl_lines', 1000))
|
||||
try:
|
||||
zone_config = StructureZoneConfig(kl_lines_per_tf=zone_kl_lines)
|
||||
if zone_timeframes_str:
|
||||
zone_config.zone_timeframes = [t.strip() for t in zone_timeframes_str.split(',') if t.strip()]
|
||||
analyses = {}
|
||||
ema52_dict = {}
|
||||
latest_close = 0.0
|
||||
now = time.time()
|
||||
|
||||
def _fetch_single_tf_zone(tf_name):
|
||||
"""单个时间周期的结构区数据拉取(线程安全)"""
|
||||
cache_key = f"{symbol}:{tf_name}:{zone_kl_lines}"
|
||||
cached = _zone_cache.get(cache_key)
|
||||
if cached and cached['expires'] > now:
|
||||
print(f" 结构区缓存命中: {tf_name}")
|
||||
return {
|
||||
'tf_name': tf_name,
|
||||
'analyses': cached['analyses'],
|
||||
'ema52': cached['ema52'],
|
||||
'close': cached.get('close', 0.0),
|
||||
'cached': True,
|
||||
}
|
||||
|
||||
try:
|
||||
tf_df = get_kl_data(symbol, tf_name, limit=zone_kl_lines)
|
||||
if tf_df is None or len(tf_df) == 0:
|
||||
return None
|
||||
tf_df = add_indicators(tf_df)
|
||||
tf_analysis = analyze_chan(tf_df, symbol, tf_name)
|
||||
zs_serialized = [{
|
||||
'start_time': (zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if zs.start_klc else None,
|
||||
'end_time': (zs.end_klc.end_time if isinstance(zs.end_klc.end_time, str) else zs.end_klc.end_time.astimezone(client_tz).isoformat()) if zs.end_klc else None,
|
||||
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
|
||||
'is_sure': zs.is_sure
|
||||
} for zs in tf_analysis.get('zs_list', []) if zs.is_sure]
|
||||
bi_zs_serialized = [{
|
||||
'start_time': ((zs.start_klc.end_time if isinstance(zs.start_klc.end_time, str) else zs.start_klc.end_time.astimezone(client_tz).isoformat()) if getattr(zs.start_klc, 'end_time', None) else (zs.start_klc.start_time if isinstance(zs.start_klc.start_time, str) else zs.start_klc.start_time.astimezone(client_tz).isoformat())),
|
||||
'end_time': (zs.end_time if isinstance(zs.end_time, str) else zs.end_time.astimezone(client_tz).isoformat()) if getattr(zs, 'end_time', None) else None,
|
||||
'zg': zs.zg, 'zd': zs.zd, 'gg': zs.gg, 'dd': zs.dd,
|
||||
'is_sure': bool(getattr(zs, 'is_sure', False))
|
||||
} for zs in tf_analysis.get('bi_zs_list', []) if getattr(zs, 'is_sure', False)]
|
||||
last_ema = tf_df['ema52'].iloc[-1] if 'ema52' in tf_df.columns else 0
|
||||
ema_val = float(last_ema) if last_ema and last_ema > 0 else None
|
||||
last_close = float(tf_df['close'].iloc[-1])
|
||||
tf_result = {
|
||||
'tf_name': tf_name,
|
||||
'analyses': {'zs_list': zs_serialized, 'bi_zs_list': bi_zs_serialized},
|
||||
'ema52': ema_val,
|
||||
'close': last_close,
|
||||
'cached': False,
|
||||
}
|
||||
# 写入缓存
|
||||
_zone_cache[cache_key] = {
|
||||
'analyses': tf_result['analyses'],
|
||||
'ema52': ema_val,
|
||||
'close': last_close,
|
||||
'expires': now + _zone_cache_ttl(tf_name),
|
||||
}
|
||||
print(f" 结构区数据: {tf_name} -> zs={len(zs_serialized)}, bi_zs={len(bi_zs_serialized)}, ema52={ema_val}")
|
||||
return tf_result
|
||||
except Exception as e:
|
||||
print(f" 结构区 {tf_name} 拉取失败: {e}")
|
||||
return None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(zone_config.zone_timeframes)) as executor:
|
||||
futures = {executor.submit(_fetch_single_tf_zone, tf): tf for tf in zone_config.zone_timeframes}
|
||||
for future in as_completed(futures):
|
||||
tf_result = future.result()
|
||||
if tf_result is None:
|
||||
continue
|
||||
tf_name = tf_result['tf_name']
|
||||
analyses[tf_name] = tf_result['analyses']
|
||||
ema52_dict[tf_name] = tf_result['ema52']
|
||||
if tf_result['close'] and (not latest_close or latest_close == 0.0):
|
||||
latest_close = tf_result['close']
|
||||
|
||||
structure_zones = analyze_structure_zones_from_serialized(
|
||||
analyses, ema52_dict, latest_close, config=zone_config
|
||||
)
|
||||
result['structure_zones'] = [{
|
||||
'id': z.id,
|
||||
'lower': z.lower,
|
||||
'upper': z.upper,
|
||||
'center': z.center,
|
||||
'width_pct': z.width_pct,
|
||||
'zone_type': z.zone_type,
|
||||
'timeframes': z.timeframes,
|
||||
'structure_types': z.structure_types,
|
||||
'boundary_types': z.boundary_types,
|
||||
'overlap_count': z.overlap_count,
|
||||
'touch_count': z.touch_count,
|
||||
'recency_score': z.recency_score,
|
||||
'ema52_distance_pct': z.ema52_distance_pct,
|
||||
'ema52_aligned': z.ema52_aligned,
|
||||
'strength_score': z.strength_score,
|
||||
'confidence': z.confidence,
|
||||
'first_seen': z.first_seen,
|
||||
'last_seen': z.last_seen,
|
||||
'metadata': z.metadata,
|
||||
} for z in structure_zones]
|
||||
except Exception as e:
|
||||
print(f"StructureZone 分析出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result['structure_zones'] = []
|
||||
else:
|
||||
result['structure_zones'] = []
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
@app.route('/api/symbols')
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.button-tFul0OhX{cursor:default;-webkit-user-select:none;user-select:none}.button-children-tFul0OhX{display:block;overflow:hidden;padding:0 2px 0 6px;text-overflow:ellipsis;white-space:nowrap;width:100%}.button-children-tFul0OhX.hiddenArrow-tFul0OhX{padding-right:6px}.invisibleFocusHandler-tFul0OhX{height:0;opacity:0;pointer-events:none;width:0}
|
||||
@@ -1 +0,0 @@
|
||||
.button-tFul0OhX{cursor:default;-webkit-user-select:none;user-select:none}.button-children-tFul0OhX{display:block;overflow:hidden;padding:0 6px 0 2px;text-overflow:ellipsis;white-space:nowrap;width:100%}.button-children-tFul0OhX.hiddenArrow-tFul0OhX{padding-left:6px}.invisibleFocusHandler-tFul0OhX{height:0;opacity:0;pointer-events:none;width:0}
|
||||
@@ -1,5 +0,0 @@
|
||||
(self.webpackChunktradingview=self.webpackChunktradingview||[]).push([[1139],{56708:e=>{e.exports={scrollWrap:"scrollWrap-FaOvTD2r"}},86388:e=>{e.exports={wrap:"wrap-vSb6C0Bj","wrap--horizontal":"wrap--horizontal-vSb6C0Bj",bar:"bar-vSb6C0Bj",barInner:"barInner-vSb6C0Bj","barInner--horizontal":"barInner--horizontal-vSb6C0Bj","bar--horizontal":"bar--horizontal-vSb6C0Bj"}},13528:(e,n,a)=>{"use strict";a.d(n,{AppContext:()=>t});const t=(0,a(79474).createContext)({isOnMobileAppPage:()=>!1,isRtl:!1,locale:"en",renderMode:"legacy"})},55971:(e,n,a)=>{"use strict";a.d(n,{useFocus:()=>o});var t=a(79474);function o(e,n){const[a,o]=(0,t.useState)(!1);(0,t.useEffect)((()=>{n&&a&&o(!1)}),[n,a]);const r={onFocus:(0,t.useCallback)((function(n){void 0!==e&&e.current!==n.target||o(!0)}),[e]),onBlur:(0,t.useCallback)((function(n){void 0!==e&&e.current!==n.target||o(!1)}),[e])};return[a,r]}},9774:(e,n,a)=>{"use strict";a.d(n,{useMergedRefs:()=>r});var t=a(79474),o=a(16455);function r(e){return(0,t.useCallback)((0,o.mergeRefs)(e),e)}},61366:(e,n,a)=>{"use strict";a.d(n,{useResizeObserver:()=>i});var t=a(79474),o=a(69947),r=a(73064);function i(e,n=[]){const{callback:a,ref:i=null}=function(e){return"function"==typeof e?{callback:e}:e}(e),s=(0,t.useRef)(null),l=(0,t.useRef)(a);l.current=a;const u=(0,r.useFunctionalRefObject)(i),c=(0,t.useCallback)((e=>{u(e),null!==s.current&&(s.current.disconnect(),null!==e&&s.current.observe(e))}),[u,s]);return(0,o.useIsomorphicLayoutEffect)((()=>(s.current=new ResizeObserver(((e,n)=>{l.current(e,n)})),u.current&&c(u.current),()=>{s.current?.disconnect()})),[u,...n]),c}},2328:(e,n,a)=>{"use strict";a.d(n,{formatTime:()=>_,isValidTimeOptionsDateStyle:()=>g,isValidTimeOptionsRange:()=>c});const t={calendar:"gregory",numberingSystem:"latn",hour12:!1},o={year:"numeric",month:"short",day:"numeric"},r={year:"numeric",month:"2-digit",day:"2-digit"},i={hour:"2-digit",minute:"2-digit",second:"2-digit"},s={timeZoneName:"shortOffset",weekday:"short"},l={year:0,month:1,day:2,hour:3,minute:4,second:5};const u=["year","month","day","hour","minute","second"];function c(e){return u.includes(e)}function g(e){return"numeric"===e||"short"===e}function _(e,n,a="year",u="day",c){const g=function(e="year",n="day",a={}){[e,n]=l[n]>l[e]?[e,n]:[n,e];const u={..."numeric"===a.dateStyle?r:o,...i},c=a.fractionalSecondDigits,g={...t,fractionalSecondDigits:void 0===c?void 0:Math.floor(Math.min(Math.max(1,c),3)),timeZone:a.timeZone,weekday:a.weekday?s.weekday:void 0,timeZoneName:a.timeZoneName?s.timeZoneName:void 0};return Object.keys(u).forEach((a=>{l[a]>=l[e]&&l[a]<=l[n]&&(g[a]=u[a])})),g}(a,u,c),_=new Intl.DateTimeFormat(n,g),d=new Date(e);return _.format(d)}},64483:(e,n,a)=>{"use strict";a.d(n,{createReactRoot:()=>g});var t=a(79474),o=a(29365),r=a(36334),i=a(13528),s=a(90141),l=a(81458);const u={iOs:"old",android:"new",old:"old",new:"new",any:"any"};function c(e){const[n]=(0,t.useState)({isOnMobileAppPage:e=>(0,s.isOnMobileAppPage)(u[e]),isRtl:(0,l.isRtl)(),locale:window.locale,renderMode:e.renderMode??"legacy"})
|
||||
;return t.createElement(i.AppContext.Provider,{value:n},e.children)}function g(e,n,a="legacy"){const i=t.createElement(c,{renderMode:a},e);if("modern"===a){const e=(0,r.createRoot)(n);return e.render(i),{render(n){e.render(t.createElement(c,{renderMode:a},n))},unmount(){e.unmount()}}}return o.render(i,n),{render(e){o.render(t.createElement(c,{renderMode:a},e),n)},unmount(){o.unmountComponentAtNode(n)}}}},94646:(e,n,a)=>{"use strict";a.d(n,{getLocaleIso:()=>r})
|
||||
;const t=JSON.parse('{"en":{"language":"en","language_name":"English","flag":"us","geoip_code":"us","iso":"en","iso_639_3":"eng","global_name":"English","is_only_recommended_tw_autorepost":true},"in":{"language":"en","language_name":"English (India)","flag":"in","geoip_code":"in","iso":"en","iso_639_3":"eng","global_name":"Indian"},"de_DE":{"language":"de","language_name":"Deutsch","flag":"de","geoip_code":"de","countries_with_this_language":["at","ch"],"iso":"de","iso_639_3":"deu","global_name":"German","is_in_european_union":true},"fr":{"language":"fr","language_name":"Français","flag":"fr","geoip_code":"fr","iso":"fr","iso_639_3":"fra","global_name":"French","is_in_european_union":true},"ca_ES":{"language":"ca_ES","language_name":"Català","flag":"es","geoip_code":"es","iso":"ca","iso_639_3":"cat","global_name":"Catalan"},"es":{"language":"es","language_name":"Español","flag":"es","geoip_code":"es","countries_with_this_language":["mx","ar","ve","cl","co","pe","uy","py","cr","gt","c","bo","pa","pr"],"iso":"es","iso_639_3":"spa","global_name":"Spanish","is_in_european_union":true},"it":{"language":"it","language_name":"Italiano","flag":"it","geoip_code":"it","iso":"it","iso_639_3":"ita","global_name":"Italian","is_in_european_union":true},"pl":{"language":"pl","language_name":"Polski","flag":"pl","geoip_code":"pl","iso":"pl","iso_639_3":"pol","global_name":"Polish","is_in_european_union":true},"hu_HU":{"language":"hu_HU","language_name":"Magyar","flag":"hu","geoip_code":"hu","iso":"hu","iso_639_3":"hun","global_name":"Hungarian","is_in_european_union":true},"sv_SE":{"language":"sv","language_name":"Svenska","flag":"se","geoip_code":"se","iso":"sv","iso_639_3":"swe","global_name":"Swedish","is_in_european_union":true},"tr":{"language":"tr","language_name":"Türkçe","flag":"tr","geoip_code":"tr","iso":"tr","iso_639_3":"tur","global_name":"Turkish","is_only_recommended_tw_autorepost":true},"ru":{"language":"ru","language_name":"Русский","flag":"ru","geoip_code":"ru","countries_with_this_language":["am","by","kg","kz","md","tj","tm","uz"],"iso":"ru","iso_639_3":"rus","global_name":"Russian","is_only_recommended_tw_autorepost":true},"br":{"language":"pt","language_name":"Português","flag":"br","geoip_code":"br","iso":"pt","iso_639_3":"por","global_name":"Portuguese"},"id":{"language":"id_ID","language_name":"Bahasa Indonesia","flag":"id","geoip_code":"id","iso":"id","iso_639_3":"ind","global_name":"Indonesian"},"ms_MY":{"language":"ms_MY","language_name":"Bahasa Melayu","flag":"my","geoip_code":"my","iso":"ms","iso_639_3":"zlm","global_name":"Malaysian"},"th_TH":{"language":"th","language_name":"ภาษาไทย","flag":"th","geoip_code":"th","iso":"th","iso_639_3":"tha","global_name":"Thai"},"vi_VN":{"language":"vi","language_name":"Tiếng Việt","flag":"vn","geoip_code":"vn","iso":"vi","iso_639_3":"vie","global_name":"Vietnamese"},"ja":{"language":"ja","language_name":"日本語","flag":"jp","geoip_code":"jp","iso":"ja","iso_639_3":"jpn","global_name":"Japanese"},"kr":{"language":"ko","language_name":"한국어","flag":"kr","geoip_code":"kr","iso":"ko","iso_639_3":"kor","global_name":"Korean"},"zh_CN":{"language":"zh","language_name":"简体中文","flag":"cn","geoip_code":"cn","countries_with_this_language":["zh"],"iso":"zh-Hans","iso_639_3":"cmn","global_name":"Chinese"},"zh_TW":{"language":"zh_TW","language_name":"繁體中文","flag":"tw","geoip_code":"tw","countries_with_this_language":["hk"],"iso":"zh-Hant","iso_639_3":"cmn","global_name":"Taiwanese"},"ar_AE":{"language":"ar","language_name":"العربية","flag":"sa","geoip_code":"sa","countries_with_this_language":["ae","bh","dj","dz","eg","er","iq","jo","km","kw","lb","ly","ma","mr","om","qa","sa","sd","so","sy","td","tn","ye"],"dir":"rtl","iso":"ar","iso_639_3":"arb","global_name":"Arabic"},"he_IL":{"language":"he_IL","language_name":"עברית","flag":"il","geoip_code":"il","dir":"rtl","iso":"he","iso_639_3":"heb","global_name":"Israeli"}}'),o=function(){
|
||||
const e=document.getElementById("page-locale-links")?.textContent,n=e?JSON.parse(e):[];if(0===n.length)return t;const a={};return n.forEach((({locale:e,url:n})=>{a[e]={...t[e],href:n}})),a}();function r(e){return e=e||window.locale,o[e]?.iso}},98808:(e,n,a)=>{"use strict";a.d(n,{OverlayScrollContainer:()=>p});var t=a(79474),o=a(14487),r=a.n(o),i=a(81458),s=a(85842),l=a(85728);const u=a(86388);var c;!function(e){e[e.Vertical=0]="Vertical",e[e.Horizontal=1]="Horizontal",e[e.HorizontalRtl=2]="HorizontalRtl"}(c||(c={}));const g={0:{isHorizontal:!1,isNegative:!1,sizePropName:"height",minSizePropName:"minHeight",startPointPropName:"top",currentMousePointPropName:"clientY",progressBarTransform:"translateY"},1:{isHorizontal:!0,isNegative:!1,sizePropName:"width",minSizePropName:"minWidth",startPointPropName:"left",currentMousePointPropName:"clientX",progressBarTransform:"translateX"},2:{isHorizontal:!0,isNegative:!0,sizePropName:"width",minSizePropName:"minWidth",startPointPropName:"right",currentMousePointPropName:"clientX",progressBarTransform:"translateX"}},_=40;function d(e){const{size:n,scrollSize:a,clientSize:o,scrollProgress:i,onScrollProgressChange:c,scrollMode:d,theme:m=u,onDragStart:f,onDragEnd:h,minBarSize:p=_}=e,b=(0,t.useRef)(null),v=(0,t.useRef)(null),[y,N]=(0,t.useState)(!1),z=(0,t.useRef)(0),{isHorizontal:w,isNegative:P,sizePropName:S,minSizePropName:E,startPointPropName:k,currentMousePointPropName:C,progressBarTransform:M}=g[d];(0,t.useEffect)((()=>{const e=(0,s.ensureNotNull)(b.current).ownerDocument;return y?(f&&f(),e&&(e.addEventListener("mousemove",O),e.addEventListener("mouseup",W))):h&&h(),()=>{e&&(e.removeEventListener("mousemove",O),e.removeEventListener("mouseup",W))}}),[y]);const R=n/a||0,H=o*R||0,T=Math.max(H,p),D=(n-T)/(n-H),I=a-n,L=P?-I:0,B=P?0:I,j=x((0,l.clamp)(i,L,B))||0;return t.createElement("div",{ref:b,className:r()(m.wrap,w&&m["wrap--horizontal"]),style:{[S]:n},onMouseDown:function(e){if(e.isDefaultPrevented())return;e.preventDefault();const n=V(e.nativeEvent,(0,s.ensureNotNull)(b.current)),a=Math.sign(n),t=(0,s.ensureNotNull)(v.current).getBoundingClientRect();z.current=a*t[S]/2;let o=Math.abs(n)-Math.abs(z.current);const r=x(I);o<0?(o=0,z.current=n):o>r&&(o=r,z.current=n-a*r);c(A(a*o)),N(!0)}},t.createElement("div",{ref:v,className:r()(m.bar,w&&m["bar--horizontal"]),style:{[E]:p,[S]:T,transform:`${M}(${j}px)`},onMouseDown:function(e){e.preventDefault(),z.current=V(e.nativeEvent,(0,s.ensureNotNull)(v.current)),N(!0)}},t.createElement("div",{className:r()(m.barInner,w&&m["barInner--horizontal"])})));function O(e){const n=V(e,(0,s.ensureNotNull)(b.current))-z.current;c(A(n))}function W(){N(!1)}function V(e,n){const a=n.getBoundingClientRect()[k];return e[C]-a}function x(e){return e*R*D}function A(e){return e/R/D}}var m=a(53530),f=a(56708);const h=8;function p(e){const{reference:n,className:a,containerHeight:r=0,containerWidth:s=0,contentHeight:l=0,contentWidth:u=0,scrollPosTop:c=0,scrollPosLeft:g=0,onVerticalChange:_,onHorizontalChange:p,visible:b}=e,[v,y]=(0,
|
||||
m.useHoverDeprecated)(),[N,z]=(0,t.useState)(!1),w=r<l,P=s<u,S=w&&P?h:0;return t.createElement("div",{...y,ref:n,className:o(a,f.scrollWrap),style:{visibility:b||v||N?"visible":"hidden"}},w&&t.createElement(d,{size:r-S,scrollSize:l-S,clientSize:r-S,scrollProgress:c,onScrollProgressChange:function(e){_&&_(e)},onDragStart:E,onDragEnd:k,scrollMode:0}),P&&t.createElement(d,{size:s-S,scrollSize:u-S,clientSize:s-S,scrollProgress:g,onScrollProgressChange:function(e){p&&p(e)},onDragStart:E,onDragEnd:k,scrollMode:(0,i.isRtl)()?2:1}));function E(){z(!0)}function k(){z(!1)}}},71515:(e,n,a)=>{"use strict";a.d(n,{useDimensions:()=>r});var t=a(79474),o=a(61366);function r(e){const[n,a]=(0,t.useState)(null),r=(0,t.useCallback)((([e])=>{const t=e.target.getBoundingClientRect();t.width===n?.width&&t.height===n.height||a(t)}),[n]);return[(0,o.useResizeObserver)({callback:r,ref:e}),n]}},56804:(e,n,a)=>{"use strict";a.d(n,{useOverlayScroll:()=>l});var t=a(79474),o=a(85842),r=a(53530),i=a(45958);const s={onMouseEnter:()=>{},onMouseLeave:()=>{}};function l(e,n=i.CheckMobile.any()){const a=(0,t.useRef)(null),l=e||(0,t.useRef)(null),[u,c]=(0,r.useHover)(),[g,_]=(0,t.useState)({reference:a,containerHeight:0,containerWidth:0,contentHeight:0,contentWidth:0,scrollPosTop:0,scrollPosLeft:0,onVerticalChange:function(e){_((n=>({...n,scrollPosTop:e}))),(0,o.ensureNotNull)(l.current).scrollTop=e},onHorizontalChange:function(e){_((n=>({...n,scrollPosLeft:e}))),(0,o.ensureNotNull)(l.current).scrollLeft=e},visible:u}),d=(0,t.useCallback)((()=>{if(!l.current)return;const{clientHeight:e,scrollHeight:n,scrollTop:t,clientWidth:o,scrollWidth:r,scrollLeft:i}=l.current,s=a.current?a.current.offsetTop:0;_((a=>({...a,containerHeight:e-s,contentHeight:n-s,scrollPosTop:t,containerWidth:o,contentWidth:r,scrollPosLeft:i})))}),[]);function m(){_((e=>({...e,scrollPosTop:(0,o.ensureNotNull)(l.current).scrollTop,scrollPosLeft:(0,o.ensureNotNull)(l.current).scrollLeft})))}return(0,t.useEffect)((()=>{u&&d(),_((e=>({...e,visible:u})))}),[u]),(0,t.useEffect)((()=>{const e=l.current;return e&&e.addEventListener("scroll",m),()=>{e&&e.removeEventListener("scroll",m)}}),[l]),[g,n?s:c,l,d]}},57069:(e,n,a)=>{"use strict";a.d(n,{useWatchedValueReadonly:()=>r});var t=a(79474),o=a(69947);const r=(e,n=!1,a=[])=>{const r="watchedValue"in e?e.watchedValue:void 0,i="defaultValue"in e?e.defaultValue:e.watchedValue.value(),[s,l]=(0,t.useState)(r?r.value():i);return(n?o.useIsomorphicLayoutEffect:t.useEffect)((()=>{if(r){l(r.value());const e=e=>l(e);return r.subscribe(e),()=>r.unsubscribe(e)}return()=>{}}),[r,...a]),s}}}]);
|
||||
@@ -1,4 +0,0 @@
|
||||
(self.webpackChunktradingview=self.webpackChunktradingview||[]).push([[1160],{66740:e=>{e.exports={button:"button-PYEOTd6i",disabled:"disabled-PYEOTd6i",hidden:"hidden-PYEOTd6i",icon:"icon-PYEOTd6i",dropped:"dropped-PYEOTd6i"}},92318:e=>{e.exports={button:"button-D4RPB3ZC",iconOnly:"iconOnly-D4RPB3ZC",withStartSlot:"withStartSlot-D4RPB3ZC",withEndSlot:"withEndSlot-D4RPB3ZC",startSlotWrap:"startSlotWrap-D4RPB3ZC",endSlotWrap:"endSlotWrap-D4RPB3ZC",xsmall:"xsmall-D4RPB3ZC",small:"small-D4RPB3ZC",medium:"medium-D4RPB3ZC",large:"large-D4RPB3ZC",xlarge:"xlarge-D4RPB3ZC",content:"content-D4RPB3ZC",link:"link-D4RPB3ZC",blue:"blue-D4RPB3ZC",primary:"primary-D4RPB3ZC",secondary:"secondary-D4RPB3ZC",gray:"gray-D4RPB3ZC",green:"green-D4RPB3ZC",red:"red-D4RPB3ZC",black:"black-D4RPB3ZC",slot:"slot-D4RPB3ZC",stretch:"stretch-D4RPB3ZC",grouped:"grouped-D4RPB3ZC",adjustPosition:"adjustPosition-D4RPB3ZC",firstRow:"firstRow-D4RPB3ZC",firstCol:"firstCol-D4RPB3ZC","no-corner-top-left":"no-corner-top-left-D4RPB3ZC","no-corner-top-right":"no-corner-top-right-D4RPB3ZC","no-corner-bottom-right":"no-corner-bottom-right-D4RPB3ZC","no-corner-bottom-left":"no-corner-bottom-left-D4RPB3ZC",textWrap:"textWrap-D4RPB3ZC",multilineContent:"multilineContent-D4RPB3ZC",primaryText:"primaryText-D4RPB3ZC",secondaryText:"secondaryText-D4RPB3ZC"}},21353:e=>{e.exports={container:"container-WDZ0PRNh","container-xxsmall":"container-xxsmall-WDZ0PRNh","container-xsmall":"container-xsmall-WDZ0PRNh","container-small":"container-small-WDZ0PRNh","container-medium":"container-medium-WDZ0PRNh","container-large":"container-large-WDZ0PRNh","intent-default":"intent-default-WDZ0PRNh",focused:"focused-WDZ0PRNh",readonly:"readonly-WDZ0PRNh",disabled:"disabled-WDZ0PRNh","with-highlight":"with-highlight-WDZ0PRNh",grouped:"grouped-WDZ0PRNh","adjust-position":"adjust-position-WDZ0PRNh","first-row":"first-row-WDZ0PRNh","first-col":"first-col-WDZ0PRNh",stretch:"stretch-WDZ0PRNh","font-size-medium":"font-size-medium-WDZ0PRNh","font-size-large":"font-size-large-WDZ0PRNh","no-corner-top-left":"no-corner-top-left-WDZ0PRNh","no-corner-top-right":"no-corner-top-right-WDZ0PRNh","no-corner-bottom-right":"no-corner-bottom-right-WDZ0PRNh","no-corner-bottom-left":"no-corner-bottom-left-WDZ0PRNh","size-xxsmall":"size-xxsmall-WDZ0PRNh","size-xsmall":"size-xsmall-WDZ0PRNh","size-small":"size-small-WDZ0PRNh","size-medium":"size-medium-WDZ0PRNh","size-large":"size-large-WDZ0PRNh","intent-success":"intent-success-WDZ0PRNh","intent-warning":"intent-warning-WDZ0PRNh","intent-danger":"intent-danger-WDZ0PRNh","intent-primary":"intent-primary-WDZ0PRNh","border-none":"border-none-WDZ0PRNh","border-thin":"border-thin-WDZ0PRNh","border-thick":"border-thick-WDZ0PRNh",highlight:"highlight-WDZ0PRNh",shown:"shown-WDZ0PRNh"}},20853:e=>{e.exports={"inner-slot":"inner-slot-W53jtLjw",interactive:"interactive-W53jtLjw",icon:"icon-W53jtLjw","inner-middle-slot":"inner-middle-slot-W53jtLjw","before-slot":"before-slot-W53jtLjw","after-slot":"after-slot-W53jtLjw"}},12725:(e,t,n)=>{"use strict";var r,o,s
|
||||
;function i(e="default"){switch(e){case"default":return"primary";case"stroke":return"secondary"}}function a(e="primary"){switch(e){case"primary":return"brand";case"success":return"green";case"default":return"gray";case"danger":return"red"}}function l(e="m"){switch(e){case"s":return"xsmall";case"m":return"small";case"l":return"large"}}n.d(t,{Button:()=>m}),function(e){e.Primary="primary",e.Success="success",e.Default="default",e.Danger="danger"}(r||(r={})),function(e){e.Small="s",e.Medium="m",e.Large="l"}(o||(o={})),function(e){e.Default="default",e.Stroke="stroke"}(s||(s={}));var c=n(79474),u=n(63459);function d(e){const{intent:t,size:n,appearance:r,useFullWidth:o,icon:s,...c}=e;return{...c,color:a(t),size:l(n),variant:i(r),stretch:o}}function m(e){return c.createElement(u.SquareButton,{...d(e)})}},91965:(e,t,n)=>{"use strict";n.d(t,{Caret:()=>m,CaretButton:()=>p});var r=n(79474),o=n(14487),s=n.n(o),i=n(73457),a=n(43616),l=n.n(a),c=n(66740),u=n.n(c);function d(e){const{isDropped:t}=e;return r.createElement(i.Icon,{className:s()(u().icon,t&&u().dropped),icon:l()})}function m(e){const{className:t,disabled:n,isDropped:o}=e;return r.createElement("span",{className:s()(u().button,n&&u().disabled,t)},r.createElement(d,{isDropped:o}))}function p(e){const{className:t,tabIndex:n=-1,disabled:o,isDropped:i,...a}=e;return r.createElement("button",{...a,type:"button",tabIndex:n,disabled:o,className:s()(u().button,o&&u().disabled,t)},r.createElement(d,{isDropped:i}))}},63459:(e,t,n)=>{"use strict";n.d(t,{SquareButton:()=>D});var r=n(79474),o=n(14487),s=n.n(o),i=n(67440),a=n(92318),l=n.n(a);const c="apply-overflow-tooltip apply-overflow-tooltip--check-children-recursively apply-overflow-tooltip--allow-text apply-common-tooltip";function u(e){const{size:t="medium",variant:n="primary",color:r="brand",stretch:o=!1,startSlot:a,endSlot:u,iconOnly:d=!1,className:m,isGrouped:p,cellState:h,disablePositionAdjustment:f=!1,primaryText:R,secondaryText:D,isAnchor:P=!1}=e,g="brand"===r?"black":r,Z=function(e){let t="";return 0!==e&&(1&e&&(t=s()(t,l()["no-corner-top-left"])),2&e&&(t=s()(t,l()["no-corner-top-right"])),4&e&&(t=s()(t,l()["no-corner-bottom-right"])),8&e&&(t=s()(t,l()["no-corner-bottom-left"]))),t}((0,i.getGroupCellRemoveRoundBorders)(h)),b=d&&(a||u);return s()(m,l().button,l()[t],l()[g],l()[n],o&&l().stretch,a&&l().withStartIcon,u&&l().withEndIcon,b&&l().iconOnly,Z,p&&l().grouped,p&&!f&&l().adjustPosition,p&&h.isTop&&l().firstRow,p&&h.isLeft&&l().firstCol,R&&D&&l().multilineContent,P&&l().link,c)}function d(e){const{startSlot:t,iconOnly:n,children:o,endSlot:i,primaryText:a,secondaryText:u}=e;if(t&&i&&n)return r.createElement("span",{className:s()(l().slot,l().startSlotWrap)},t);const d=n&&(t??i),m=!t&&!i&&!n&&!o&&a&&u;return r.createElement(r.Fragment,null,t&&r.createElement("span",{className:s()(l().slot,l().startSlotWrap)},t),o&&!d&&r.createElement("span",{className:l().content},o),i&&r.createElement("span",{className:s()(l().slot,l().endSlotWrap)},i),m&&!d&&function(e){return e.primaryText&&e.secondaryText&&r.createElement("div",{
|
||||
className:s()(l().textWrap,c)},r.createElement("span",{className:l().primaryText}," ",e.primaryText," "),"string"==typeof e.secondaryText?r.createElement("span",{className:l().secondaryText}," ",e.secondaryText," "):r.createElement("span",{className:l().secondaryText},r.createElement("span",null,e.secondaryText.firstLine),r.createElement("span",null,e.secondaryText.secondLine)))}(e))}var m=n(27914),p=n(59794),h=n(40197);function f(e,t){return n=>{if(t)return n.preventDefault(),void n.stopPropagation();e?.(n)}}function R(e){const{className:t,color:n,variant:r,size:o,stretch:s,iconOnly:i,startSlot:a,endSlot:l,primaryText:c,secondaryText:u,...d}=e;return{...d,...(0,h.filterDataProps)(e),...(0,h.filterAriaProps)(e)}}function D(e){const{reference:t,tooltipText:n,disabled:o,onClick:s,onMouseOver:i,onMouseOut:a,onMouseDown:l,onMouseEnter:c,"aria-disabled":h,...D}=e,{isGrouped:P,cellState:g,disablePositionAdjustment:Z}=(0,r.useContext)(p.ControlGroupContext),b=u({...D,isGrouped:P,cellState:g,disablePositionAdjustment:Z}),C=n??(e.primaryText?[e.primaryText,e.secondaryText].join(" "):(0,m.getTextForTooltip)(e.children));return r.createElement("button",{...R(D),"aria-disabled":o||h,tabIndex:e.tabIndex??(o?-1:0),className:b,ref:t,onClick:f(s,o),onMouseDown:f(l,o),onMouseOver:f(i,o),onMouseOut:f(a,o),onMouseEnter:f(c,o),"data-overflow-tooltip-text":C},r.createElement(d,{...D}))}n(90741)},59794:(e,t,n)=>{"use strict";n.d(t,{ControlGroupContext:()=>r});const r=n(79474).createContext({isGrouped:!1,cellState:{isTop:!0,isRight:!0,isBottom:!0,isLeft:!0}})},67440:(e,t,n)=>{"use strict";function r(e){let t=0;return e.isTop&&e.isLeft||(t+=1),e.isTop&&e.isRight||(t+=2),e.isBottom&&e.isLeft||(t+=8),e.isBottom&&e.isRight||(t+=4),t}n.d(t,{getGroupCellRemoveRoundBorders:()=>r})},13621:(e,t,n)=>{"use strict";n.d(t,{ControlSkeleton:()=>g,InputClasses:()=>R});var r=n(79474),o=n(14487),s=n.n(o),i=n(85842),a=n(9774),l=n(40197),c=n(59794),u=n(67440);var d=n(21353),m=n.n(d);function p(e){let t="";return 0!==e&&(1&e&&(t=s()(t,m()["no-corner-top-left"])),2&e&&(t=s()(t,m()["no-corner-top-right"])),4&e&&(t=s()(t,m()["no-corner-bottom-right"])),8&e&&(t=s()(t,m()["no-corner-bottom-left"]))),t}function h(e,t,n,r){const{removeRoundBorder:o,className:i,intent:a="default",borderStyle:l="thin",size:c,highlight:d,disabled:h,readonly:f,stretch:R,noReadonlyStyles:D,isFocused:P}=e,g=p(o??(0,u.getGroupCellRemoveRoundBorders)(n));return s()(m().container,m()[`container-${c}`],m()[`intent-${a}`],m()[`border-${l}`],c&&m()[`size-${c}`],g,d&&m()["with-highlight"],h&&m().disabled,f&&!D&&m().readonly,P&&m().focused,R&&m().stretch,t&&m().grouped,!r&&m()["adjust-position"],n.isTop&&m()["first-row"],n.isLeft&&m()["first-col"],i)}function f(e,t,n){const{highlight:r,highlightRemoveRoundBorder:o}=e;if(!r)return m().highlight;const i=p(o??(0,u.getGroupCellRemoveRoundBorders)(t));return s()(m().highlight,m().shown,m()[`size-${n}`],i)}const R={FontSizeMedium:(0,i.ensureDefined)(m()["font-size-medium"]),FontSizeLarge:(0,i.ensureDefined)(m()["font-size-large"])},D={passive:!1}
|
||||
;function P(e,t){const{style:n,id:o,role:s,onFocus:i,onBlur:u,onMouseOver:d,onMouseOut:m,onMouseDown:p,onMouseUp:R,onKeyDown:P,onClick:g,tabIndex:Z,startSlot:b,middleSlot:C,endSlot:y,onWheel:N,onWheelNoPassive:x=null,size:W,tag:B="span",type:w}=e,{isGrouped:S,cellState:v,disablePositionAdjustment:E=!1}=(0,r.useContext)(c.ControlGroupContext),T=function(e,t=null,n){const o=(0,r.useRef)(null),s=(0,r.useRef)(null),i=(0,r.useCallback)((()=>{if(null===o.current||null===s.current)return;const[e,t,n]=s.current;null!==t&&o.current.addEventListener(e,t,n)}),[]),a=(0,r.useCallback)((()=>{if(null===o.current||null===s.current)return;const[e,t,n]=s.current;null!==t&&o.current.removeEventListener(e,t,n)}),[]),l=(0,r.useCallback)((e=>{a(),o.current=e,i()}),[]);return(0,r.useEffect)((()=>(s.current=[e,t,n],i(),a)),[e,t,n]),l}("wheel",x,D),z=B;return r.createElement(z,{type:w,style:n,id:o,role:s,className:h(e,S,v,E),tabIndex:Z,ref:(0,a.useMergedRefs)([t,T]),onFocus:i,onBlur:u,onMouseOver:d,onMouseOut:m,onMouseDown:p,onMouseUp:R,onKeyDown:P,onClick:g,onWheel:N,...(0,l.filterDataProps)(e),...(0,l.filterAriaProps)(e)},b,C,y,r.createElement("span",{className:f(e,v,W)}))}P.displayName="ControlSkeleton";const g=r.forwardRef(P)},78484:(e,t,n)=>{"use strict";n.d(t,{AfterSlot:()=>d,EndSlot:()=>u,MiddleSlot:()=>c,StartSlot:()=>l});var r=n(79474),o=n(14487),s=n.n(o),i=n(20853),a=n.n(i);function l(e){const{className:t,interactive:n=!0,icon:o=!1,children:i}=e;return r.createElement("span",{className:s()(a()["inner-slot"],n&&a().interactive,o&&a().icon,t)},i)}function c(e){const{className:t,children:n}=e;return r.createElement("span",{className:s()(a()["inner-slot"],a()["inner-middle-slot"],t)},n)}function u(e){const{className:t,interactive:n=!0,icon:o=!1,children:i,dataQaId:l}=e;return r.createElement("span",{className:s()(a()["inner-slot"],n&&a().interactive,o&&a().icon,t),"data-qa-id":l},i)}function d(e){const{className:t,children:n,dataQaId:o}=e;return r.createElement("span",{className:s()(a()["after-slot"],t),"data-qa-id":o},n)}}}]);
|
||||
@@ -1,12 +0,0 @@
|
||||
(self.webpackChunktradingview=self.webpackChunktradingview||[]).push([[1178],{73832:e=>{e.exports={favorite:"favorite-_FRQhM5Y",hovered:"hovered-_FRQhM5Y",disabled:"disabled-_FRQhM5Y",focused:"focused-_FRQhM5Y",active:"active-_FRQhM5Y",checked:"checked-_FRQhM5Y"}},28390:(e,o,t)=>{"use strict";t.d(o,{useActiveDescendant:()=>n});var l=t(79474),i=t(73064);function n(e,o=[]){const[t,n]=(0,l.useState)(!1),a=(0,i.useFunctionalRefObject)(e);return(0,l.useLayoutEffect)((()=>{const e=a.current;if(null===e)return;const o=e=>{switch(e.type){case"active-descendant-focus":n(!0);break;case"active-descendant-blur":n(!1)}};return e.addEventListener("active-descendant-focus",o),e.addEventListener("active-descendant-blur",o),()=>{e.removeEventListener("active-descendant-focus",o),e.removeEventListener("active-descendant-blur",o)}}),o),[a,t]}},92381:(e,o,t)=>{"use strict";t.d(o,{RemoveTitleType:()=>l,removeTitlesMap:()=>n});var l,i=t(91599);!function(e){e.Add="add",e.Remove="remove"}(l||(l={}));const n={[l.Add]:i.t(null,void 0,t(99529)),[l.Remove]:i.t(null,void 0,t(16590))}},62466:(e,o,t)=>{"use strict";t.d(o,{FavoriteButton:()=>d});var l=t(79474),i=t(14487),n=t.n(i),a=t(66334),r=t(92381),s=t(28390),c=t(72995),v=t(89658),h=t(73832);function d(e){const{className:o,isFilled:t,isActive:i,onClick:d,title:m,...u}=e,[g,L]=(0,s.useActiveDescendant)(null),T=m??(t?r.removeTitlesMap[r.RemoveTitleType.Remove]:r.removeTitlesMap[r.RemoveTitleType.Add]);return(0,l.useLayoutEffect)((()=>{const e=g.current;e instanceof HTMLElement&&T&&e.dispatchEvent(new CustomEvent("common-tooltip-update"))}),[T,g]),l.createElement(a.Icon,{...u,className:n()(h.favorite,"apply-common-tooltip",t&&h.checked,i&&h.active,L&&h.focused,o),onClick:d,icon:t?c:v,title:T,ariaLabel:T,ref:g})}},60714:(e,o,t)=>{"use strict";t.d(o,{focusFirstMenuItem:()=>v,handleAccessibleMenuFocus:()=>s,handleAccessibleMenuKeyDown:()=>c,queryMenuElements:()=>m});var l=t(78122),i=t(87918),n=t(23351),a=t(45280);const r=[37,39,38,40];function s(e,o){if(!e.target)return;const t=e.relatedTarget?.getAttribute("aria-activedescendant");if(e.relatedTarget!==o.current){const e=t&&document.getElementById(t);if(!e||e!==o.current)return}v(e.target)}function c(e){if(e.defaultPrevented)return;const o=(0,n.hashFromEvent)(e);if(!r.includes(o))return;const t=document.activeElement;if(!(document.activeElement instanceof HTMLElement))return;const a=m(e.currentTarget).sort(l.navigationOrderComparator);if(0===a.length)return;const s=document.activeElement.closest('[data-role="menuitem"]')||document.activeElement.parentElement?.querySelector('[data-role="menuitem"]');if(!(s instanceof HTMLElement))return;const c=a.indexOf(s);if(-1===c)return;const v=u(s),g=v.indexOf(document.activeElement),L=-1!==g,T=e=>{t&&(0,i.becomeSecondaryElement)(t),(0,i.becomeMainElement)(e),e.focus()};switch((0,l.mapKeyCodeToDirection)(o)){case"inlinePrev":if(!v.length)return;e.preventDefault(),T(0===g?a[c]:L?h(v,g,-1):v[v.length-1]);break;case"inlineNext":if(!v.length)return;e.preventDefault(),g===v.length-1?T(a[c]):T(L?h(v,g,1):v[0]);break
|
||||
;case"blockPrev":{e.preventDefault();const o=h(a,c,-1);if(L){const e=d(o,g);T(e||o);break}T(o);break}case"blockNext":{e.preventDefault();const o=h(a,c,1);if(L){const e=d(o,g);T(e||o);break}T(o)}}}function v(e){const[o]=m(e);o&&((0,i.becomeMainElement)(o),o.focus())}function h(e,o,t){return e[(o+e.length+t)%e.length]}function d(e,o){const t=u(e);return t.length?t[(o+t.length)%t.length]:null}function m(e){return Array.from(e.querySelectorAll('[data-role="menuitem"]:not([disabled]):not([aria-disabled="true" i])')).filter((0,a.createScopedVisibleElementFilter)(e))}function u(e){return Array.from(e.querySelectorAll('[tabindex]:not([disabled]):not([aria-disabled="true" i])')).filter((0,a.createScopedVisibleElementFilter)(e))}},20360:(e,o,t)=>{"use strict";t.d(o,{drawingToolsIcons:()=>l});const l={SyncDrawing:t(30934),arrow:t(39669),cursor:t(61206),dot:t(84539),demonstration:t(62874),performance:"",drawginmode:t(22313),drawginmodeActive:t(31061),eraser:t(16962),group:t(6955),hideAllDrawings:t(1607),hideAllDrawingsActive:t(30252),hideAllIndicators:t(43381),hideAllIndicatorsActive:t(34491),hideAllDrawingTools:t(14798),hideAllDrawingToolsActive:t(49604),hideAllPositionsTools:t(56073),hideAllPositionsToolsActive:t(8099),lockAllDrawings:t(97941),lockAllDrawingsActive:t(86766),magnet:t(43220),heart:t(14746),smile:t(53874),sticker:t(27215),strongMagnet:t(5454),measure:t(26130),removeAllDrawingTools:t(62494),showObjectsTree:t(59204),zoom:t(28697),"zoom-out":t(78120)}},90454:(e,o,t)=>{"use strict";t.d(o,{isLineTool:()=>d,isLineToolOption:()=>m,isLineToolSwitcherOption:()=>u,isLineToolsGroupWithSections:()=>h,lineTools:()=>v,lineToolsFlat:()=>g});var l=t(91599),i=t(45958),n=t(16905),a=t(7132),r=t(7321);const s=(0,n.isFeaturesetEnabled)("image_drawingtool"),c=!i.CheckMobile.any()&&(0,n.isFeaturesetEnabled)("long_press_floating_tooltip"),v=[{id:"linetool-group-cursors",title:l.t(null,void 0,t(94409)),sections:[{items:[{name:"cursor"},{name:"dot"},{name:"arrow"},{name:"demonstration"},null].filter(r.isExistent)},{items:[{name:"eraser"},c?{type:"switcher",reactKey:"values-tooltip-on-long-press",label:l.t(null,void 0,t(52080)),value:"valuesTooltipOnLongPress",watchedValue:a.chartFloatingTooltipEnabledWV}:null].filter(r.isExistent)}],trackLabel:null},{id:"linetool-group-trend-line",title:l.t(null,void 0,t(63579)),sections:[{title:l.t(null,void 0,t(99758)),items:[{name:"LineToolTrendLine"},{name:"LineToolRay"},{name:"LineToolInfoLine"},{name:"LineToolExtended"},{name:"LineToolTrendAngle"},{name:"LineToolHorzLine"},{name:"LineToolHorzRay"},{name:"LineToolVertLine"},{name:"LineToolCrossLine"}]},{title:l.t(null,void 0,t(46035)),items:[{name:"LineToolParallelChannel"},{name:"LineToolRegressionTrend"},{name:"LineToolFlatBottom"},{name:"LineToolDisjointAngle"}]},{title:l.t(null,void 0,t(91261)),items:[{name:"LineToolPitchfork"},{name:"LineToolSchiffPitchfork2"},{name:"LineToolSchiffPitchfork"},{name:"LineToolInsidePitchfork"}]}],trackLabel:null},{id:"linetool-group-gann-and-fibonacci",title:l.t(null,void 0,t(75131)),sections:[{
|
||||
title:l.t(null,void 0,t(36651)),items:[{name:"LineToolFibRetracement"},{name:"LineToolTrendBasedFibExtension"},{name:"LineToolFibChannel"},{name:"LineToolFibTimeZone"},{name:"LineToolFibSpeedResistanceFan"},{name:"LineToolTrendBasedFibTime"},{name:"LineToolFibCircles"},{name:"LineToolFibSpiral"},{name:"LineToolFibSpeedResistanceArcs"},{name:"LineToolFibWedge"},{name:"LineToolPitchfan"}]},{title:l.t(null,void 0,t(46083)),items:[{name:"LineToolGannSquare"},{name:"LineToolGannFixed"},{name:"LineToolGannComplex"},{name:"LineToolGannFan"}]}],trackLabel:null},{id:"linetool-group-patterns",title:l.t(null,void 0,t(54328)),sections:[{title:l.t(null,void 0,t(54328)),items:[{name:"LineTool5PointsPattern"},{name:"LineToolCypherPattern"},{name:"LineToolHeadAndShoulders"},{name:"LineToolABCD"},{name:"LineToolTrianglePattern"},{name:"LineToolThreeDrivers"}]},{title:l.t(null,void 0,t(60549)),items:[{name:"LineToolElliottImpulse"},{name:"LineToolElliottCorrection"},{name:"LineToolElliottTriangle"},{name:"LineToolElliottDoubleCombo"},{name:"LineToolElliottTripleCombo"}]},{title:l.t(null,void 0,t(5294)),items:[{name:"LineToolCircleLines"},{name:"LineToolTimeCycles"},{name:"LineToolSineLine"}]}],trackLabel:null},{id:"linetool-group-prediction-and-measurement",title:l.t(null,void 0,t(72132)),sections:[{title:l.t(null,void 0,t(53332)),items:[{name:"LineToolRiskRewardLong"},{name:"LineToolRiskRewardShort"},{name:"LineToolPrediction"},{name:"LineToolBarsPattern"},{name:"LineToolGhostFeed"},{name:"LineToolProjection"}].filter(r.isExistent)},{title:l.t(null,void 0,t(28073)),items:[{name:"LineToolAnchoredVWAP"},{name:"LineToolFixedRangeVolumeProfile"},null].filter(r.isExistent)},{title:l.t(null,void 0,t(66688)),items:[{name:"LineToolPriceRange"},{name:"LineToolDateRange"},{name:"LineToolDateAndPriceRange"}]}],trackLabel:null},{id:"linetool-group-geometric-shapes",title:l.t(null,void 0,t(29345)),sections:[{title:l.t(null,void 0,t(93202)),items:[{name:"LineToolBrush"},{name:"LineToolHighlighter"}]},{title:l.t(null,void 0,t(52374)),items:[{name:"LineToolArrowMarker"},{name:"LineToolArrow"},{name:"LineToolArrowMarkUp"},{name:"LineToolArrowMarkDown"},{name:"LineToolArrowMarkLeft"},{name:"LineToolArrowMarkRight"}].filter(r.isExistent)},{title:l.t(null,void 0,t(28534)),items:[{name:"LineToolRectangle"},{name:"LineToolRotatedRectangle"},{name:"LineToolPath"},{name:"LineToolCircle"},{name:"LineToolEllipse"},{name:"LineToolPolyline"},{name:"LineToolTriangle"},{name:"LineToolArc"},{name:"LineToolBezierQuadro"},{name:"LineToolBezierCubic"}]}],trackLabel:null},{id:"linetool-group-annotation",title:l.t(null,void 0,t(79454)),sections:[{title:l.t(null,void 0,t(10983)),items:[{name:"LineToolText"},{name:"LineToolTextAbsolute"},{name:"LineToolTextNote"},{name:"LineToolPriceNote"},{name:"LineToolNote"},{name:"LineToolTable"},{name:"LineToolCallout"},{name:"LineToolComment"},{name:"LineToolPriceLabel"},{name:"LineToolSignpost"},{name:"LineToolFlagMark"}].filter(r.isExistent)},{title:l.t(null,void 0,t(19943)),items:[s?{name:"LineToolImage"
|
||||
}:null,null,null].filter(r.isExistent)}],trackLabel:null}];function h(e){return"sections"in e}function d(e){return"name"in e}function m(e){return"type"in e}function u(e){return m(e)&&"switcher"===e.type}const g=v.map((function(e){return h(e)?e.sections.map((e=>e.items.filter(d))).flat():e.items.filter(d)})).flat()},27559:(e,o,t)=>{"use strict";t.d(o,{lineToolsInfo:()=>f});var l=t(85842),i=t(91599),n=t(70327),a=(t(53225),t(70644)),r=t(20360);const s={SyncDrawing:i.t(null,void 0,t(55519)),arrow:i.t(null,void 0,t(51979)),cursor:i.t(null,void 0,t(88180)),demonstration:i.t(null,void 0,t(2521)),dot:i.t(null,void 0,t(56191)),performance:i.t(null,void 0,t(81183)),drawginmode:i.t(null,void 0,t(76659)),eraser:i.t(null,void 0,t(71697)),group:i.t(null,void 0,t(99282)),hideAllDrawings:i.t(null,void 0,t(32320)),lockAllDrawings:i.t(null,void 0,t(17768)),magnet:i.t(null,void 0,t(46656)),measure:i.t(null,void 0,t(69034)),removeAllDrawingTools:i.t(null,void 0,t(21665)),showObjectsTree:i.t(null,void 0,t(52616)),zoom:i.t(null,void 0,t(2632)),"zoom-out":i.t(null,void 0,t(92848))};var c=t(56469),v=t(23351),h=t(88994);const d=(0,v.humanReadableModifiers)(v.Modifiers.Shift,!1).trim(),m=(0,v.humanReadableModifiers)(v.Modifiers.Alt,!1).trim(),u=(0,v.humanReadableModifiers)(v.Modifiers.Mod,!1).trim(),g={keys:[d],text:i.t(null,void 0,t(12256))},L={keys:[d],text:i.t(null,void 0,t(88343))},T={keys:[d],text:i.t(null,void 0,t(36954))},w={LineTool5PointsPattern:{},LineToolABCD:{},LineToolArc:{},LineToolArrow:{},LineToolArrowMarkDown:{},LineToolArrowMarkLeft:{},LineToolArrowMarkRight:{},LineToolArrowMarkUp:{},LineToolComment:{},LineToolBarsPattern:{},LineToolBezierCubic:{},LineToolBezierQuadro:{},LineToolBrush:{},LineToolCallout:{},LineToolCircleLines:{},LineToolCypherPattern:{},LineToolDateAndPriceRange:{},LineToolDateRange:{},LineToolDisjointAngle:{hotKey:(0,n.hotKeySerialize)(g)},LineToolElliottCorrection:{},LineToolElliottDoubleCombo:{},LineToolElliottImpulse:{},LineToolElliottTriangle:{},LineToolElliottTripleCombo:{},LineToolEllipse:{hotKey:(0,n.hotKeySerialize)(L)},LineToolExtended:{},LineToolFibChannel:{},LineToolFibCircles:{hotKey:(0,n.hotKeySerialize)(L)},LineToolFibRetracement:{},LineToolFibSpeedResistanceArcs:{},LineToolFibSpeedResistanceFan:{hotKey:(0,n.hotKeySerialize)(T)},LineToolFibSpiral:{},LineToolFibTimeZone:{},LineToolFibWedge:{},LineToolFlagMark:{},LineToolFlatBottom:{hotKey:(0,n.hotKeySerialize)(g)},LineToolAnchoredVWAP:{},LineToolGannComplex:{},LineToolGannFixed:{},LineToolGannFan:{},LineToolGannSquare:{hotKey:(0,n.hotKeySerialize)({keys:[d],text:i.t(null,void 0,t(35875))})},LineToolHeadAndShoulders:{},LineToolHorzLine:{hotKey:(0,n.hotKeySerialize)({keys:[m,"H"],text:"{0} + {1}"})},LineToolHorzRay:{},LineToolIcon:{},LineToolImage:{},LineToolEmoji:{},LineToolSticker:{},LineToolInsidePitchfork:{},LineToolNote:{},LineToolSignpost:{},LineToolParallelChannel:{hotKey:(0,n.hotKeySerialize)(g)},LineToolPitchfan:{},LineToolPitchfork:{},LineToolPolyline:{},LineToolPath:{},LineToolPrediction:{},LineToolPriceLabel:{},LineToolPriceNote:{
|
||||
hotKey:(0,n.hotKeySerialize)(g)},LineToolTextNote:{},LineToolArrowMarker:{},LineToolPriceRange:{},LineToolProjection:{},LineToolRay:{},LineToolRectangle:{hotKey:(0,n.hotKeySerialize)({keys:[d],text:i.t(null,void 0,t(36954))})},LineToolCircle:{},LineToolRegressionTrend:{},LineToolRiskRewardLong:{},LineToolRiskRewardShort:{},LineToolFixedRangeVolumeProfile:{},LineToolRotatedRectangle:{hotKey:(0,n.hotKeySerialize)(g)},LineToolSchiffPitchfork:{},LineToolSchiffPitchfork2:{},LineToolSineLine:{},LineToolText:{},LineToolTextAbsolute:{},LineToolThreeDrivers:{},LineToolTimeCycles:{},LineToolTrendAngle:{hotKey:(0,n.hotKeySerialize)(g)},LineToolTrendBasedFibExtension:{},LineToolTrendBasedFibTime:{},LineToolTrendLine:{hotKey:(0,n.hotKeySerialize)(g)},LineToolInfoLine:{},LineToolTriangle:{},LineToolTrianglePattern:{},LineToolVertLine:{hotKey:(0,n.hotKeySerialize)({keys:[m,"V"],text:"{0} + {1}"})},LineToolCrossLine:{},LineToolHighlighter:{},LineToolGhostFeed:{},LineToolTable:{},SyncDrawing:{iconActive:r.drawingToolsIcons.SyncDrawingActive},arrow:{},cursor:{},dot:{},demonstration:{hotKey:(0,n.hotKeySerialize)({keys:[m],text:i.t(null,void 0,t(63366))})},drawginmode:{iconActive:r.drawingToolsIcons.drawginmodeActive},eraser:{},group:{},hideAllDrawings:{iconActive:r.drawingToolsIcons.hideAllDrawingsActive,hotKey:(0,n.hotKeySerialize)({keys:[u,m,"H"],text:"{0} + {1} + {2}"})},lockAllDrawings:{iconActive:r.drawingToolsIcons.lockAllDrawingsActive},magnet:{hotKey:(0,n.hotKeySerialize)({keys:[u],text:"{0}"})},measure:{hotKey:(0,n.hotKeySerialize)({keys:[d],text:i.t(null,void 0,t(43957))})},removeAllDrawingTools:{},showObjectsTree:{},zoom:{},"zoom-out":{}};const f={};Object.entries(w).map((([e,o])=>{const t=a.lineToolsIcons[e]??r.drawingToolsIcons[e];(0,l.assert)(!!t,`Icon is not defined for drawing "${e}"`);const i=c.lineToolsLocalizedNames[e]??s[e];(0,l.assert)(!!i,`Localized name is not defined for drawing "${e}"`);return{...o,name:e,icon:t,localizedName:i,selectHotkey:h.lineToolsSelectHotkeys[e]}})).forEach((e=>{f[e.name]=e}))},95238:(e,o,t)=>{"use strict";t.d(o,{LinetoolsFavoritesStore:()=>c});var l=t(36870),i=t(7321),n=t(82287);const a=["LineToolBalloon","LineToolNoteAbsolute",null,null].filter(i.isExistent),r=!1;var s,c;!function(e){function o(){e.favorites=[];let o=!1;const l=Boolean(void 0===(0,n.getValue)("chart.favoriteDrawings")),s=(0,n.getJSON)("chart.favoriteDrawings",[]);if(0===s.length&&l&&"undefined"!=typeof window){const e=JSON.parse(window.urlParams?.favorites??"{}").drawingTools;e&&Array.isArray(e)&&s.push(...e)}s.forEach(((l,i)=>{const n=l.tool||l;t(n)?a.includes(n)?o=!0:e.favorites.push(n):r&&r.includes(n)&&e.hiddenToolsPositions.set(n,i)})),o&&i(),e.favoritesSynced.fire()}function t(e){return"string"==typeof e&&""!==e&&!(r&&r.includes(e))}function i(o){const t=e.favorites.slice();e.hiddenToolsPositions.forEach(((e,o)=>{t.splice(e,0,o)})),(0,n.setJSON)("chart.favoriteDrawings",t,o)}e.favorites=[],e.favoritesSynced=new l.Delegate,e.hiddenToolsPositions=new Map,e.favoriteIndex=function(o){return e.favorites.indexOf(o)},
|
||||
e.isValidLineToolName=t,e.saveFavorites=i,o(),n.onSync.subscribe(null,o)}(s||(s={})),function(e){function o(e){return s.isValidLineToolName(e)}function t(){return s.favorites.length}function i(e){return-1!==s.favoriteIndex(e)}e.favoriteAdded=new l.Delegate,e.favoriteRemoved=new l.Delegate,e.favoriteMoved=new l.Delegate,e.favoritesSynced=s.favoritesSynced,e.favorites=function(){return s.favorites.slice()},e.isValidLineToolName=o,e.favoritesCount=t,e.favorite=function(e){return e<0||e>=t()?"":s.favorites[e]},e.addFavorite=function(t,l){return!(i(t)||!o(t)||"performance"===t)&&(s.favorites.push(t),s.saveFavorites(l),e.favoriteAdded.fire(t),!0)},e.removeFavorite=function(o,t){const l=s.favoriteIndex(o);if(-1===l)return!1;s.favorites.splice(l,1);const i=s.hiddenToolsPositions;return i.forEach(((e,o)=>{e>l&&i.set(o,e-1)})),s.saveFavorites(t),e.favoriteRemoved.fire(o),!0},e.isFavorite=i,e.moveFavorite=function(l,i,n){if(i<0||i>=t()||!o(l))return!1;const a=s.favoriteIndex(l);if(-1===a||i===a)return!1;const r=s.hiddenToolsPositions;return r.forEach(((e,o)=>{a<e&&i>e?e--:i<e&&a>e&&e++,r.set(o,e)})),s.favorites.splice(a,1),s.favorites.splice(i,0,l),s.saveFavorites(n),e.favoriteMoved.fire(l,a,i),!0}}(c||(c={}))},62874:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28" fill="currentColor"><path d="m11.26 21 3.65-4.78 6.09-.66L10 8zm3.09-5.71-2.33 3.05-.8-8.3 7.02 4.82z"/><path fill-rule="evenodd" d="M25 14a11 11 0 1 1-22 0 11 11 0 0 1 22 0m-1 0a10 10 0 1 1-20 0 10 10 0 0 1 20 0"/></svg>'},22313:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" d="M17.27 4.56a2.5 2.5 0 0 0-3.54 0l-.58.59-9 9-1 1-.15.14V20h4.7l.15-.15 1-1 9-9 .59-.58a2.5 2.5 0 0 0 0-3.54l-1.17-1.17Zm-2.83.7a1.5 1.5 0 0 1 2.12 0l1.17 1.18a1.5 1.5 0 0 1 0 2.12l-.23.23-3.3-3.29.24-.23Zm-.94.95 3.3 3.29-8.3 8.3-3.3-3.3 8.3-8.3Zm-9 9 3.3 3.29-.5.5H4v-3.3l.5-.5Zm16.5.29a1.5 1.5 0 0 0-3 0V18h4.5c.83 0 1.5.67 1.5 1.5v4c0 .83-.67 1.5-1.5 1.5h-6a1.5 1.5 0 0 1-1.5-1.5v-4c0-.83.67-1.5 1.5-1.5h.5v-2.5a2.5 2.5 0 0 1 5 0v.5h-1v-.5ZM16.5 19a.5.5 0 0 0-.5.5v4c0 .28.22.5.5.5h6a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 0-.5-.5h-6Zm2.5 4v-2h1v2h-1Z"/></svg>'},31061:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" d="M17.27 4.56a2.5 2.5 0 0 0-3.54 0l-.58.59-9 9-1 1-.15.14V20h4.7l.15-.15 1-1 9-9 .59-.58a2.5 2.5 0 0 0 0-3.54l-1.17-1.17Zm-2.83.7a1.5 1.5 0 0 1 2.12 0l1.17 1.18a1.5 1.5 0 0 1 0 2.12l-.23.23-3.3-3.29.24-.23Zm-.94.95 3.3 3.29-8.3 8.3-3.3-3.3 8.3-8.3Zm-9 9 3.3 3.29-.5.5H4v-3.3l.5-.5Zm16.5.29a1.5 1.5 0 0 0-3 0V18h3v-2.5Zm1 0V18h.5c.83 0 1.5.67 1.5 1.5v4c0 .83-.67 1.5-1.5 1.5h-6a1.5 1.5 0 0 1-1.5-1.5v-4c0-.83.67-1.5 1.5-1.5h.5v-2.5a2.5 2.5 0 0 1 5 0ZM16.5 19a.5.5 0 0 0-.5.5v4c0 .28.22.5.5.5h6a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 0-.5-.5h-6Zm2.5 4v-2h1v2h-1Z"/></svg>'},6955:e=>{
|
||||
e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" width="30" height="30"><path fill="currentColor" d="M5.5 13A2.5 2.5 0 0 0 3 15.5 2.5 2.5 0 0 0 5.5 18 2.5 2.5 0 0 0 8 15.5 2.5 2.5 0 0 0 5.5 13zm9.5 0a2.5 2.5 0 0 0-2.5 2.5A2.5 2.5 0 0 0 15 18a2.5 2.5 0 0 0 2.5-2.5A2.5 2.5 0 0 0 15 13zm9.5 0a2.5 2.5 0 0 0-2.5 2.5 2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0 2.5-2.5 2.5 2.5 0 0 0-2.5-2.5z"/></svg>'},39669:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" d="M11.682 16.09l3.504 6.068 1.732-1-3.497-6.057 3.595-2.1L8 7.74v10.512l3.682-2.163zm-.362 1.372L7 20V6l12 7-4.216 2.462 3.5 6.062-3.464 2-3.5-6.062z"/></svg>'},61206:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><g fill="currentColor"><path d="M18 15h8v-1h-8z"/><path d="M14 18v8h1v-8zM14 3v8h1v-8zM3 15h8v-1h-8z"/></g></svg>'},84539:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><circle fill="currentColor" cx="14" cy="14" r="3"/></svg>'},16962:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 29 31" width="29" height="31"><g fill="currentColor" fill-rule="nonzero"><path d="M15.3 22l8.187-8.187c.394-.394.395-1.028.004-1.418l-4.243-4.243c-.394-.394-1.019-.395-1.407-.006l-11.325 11.325c-.383.383-.383 1.018.007 1.407l1.121 1.121h7.656zm-9.484-.414c-.781-.781-.779-2.049-.007-2.821l11.325-11.325c.777-.777 2.035-.78 2.821.006l4.243 4.243c.781.781.78 2.048-.004 2.832l-8.48 8.48h-8.484l-1.414-1.414z"/><path d="M13.011 22.999h7.999v-1h-7.999zM13.501 11.294l6.717 6.717.707-.707-6.717-6.717z"/></g></svg>'},14746:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M24.13 14.65a6.2 6.2 0 0 0-.46-9.28c-2.57-2.09-6.39-1.71-8.75.6l-.92.91-.92-.9c-2.36-2.32-6.18-2.7-8.75-.61a6.2 6.2 0 0 0-.46 9.28l9.07 8.92c.58.57 1.53.57 2.12 0l9.07-8.92Zm-9.77 8.2 9.07-8.91a5.2 5.2 0 0 0-.39-7.8c-2.13-1.73-5.38-1.45-7.42.55L14 8.29l-1.62-1.6c-2.03-2-5.29-2.28-7.42-.55a5.2 5.2 0 0 0-.4 7.8l9.08 8.91c.2.2.52.2.72 0Z"/></svg>'},43220:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><g fill="currentColor" fill-rule="evenodd"><path fill-rule="nonzero" d="M14 10a2 2 0 0 0-2 2v11H6V12c0-4.416 3.584-8 8-8s8 3.584 8 8v11h-6V12a2 2 0 0 0-2-2zm-3 2a3 3 0 0 1 6 0v10h4V12c0-3.864-3.136-7-7-7s-7 3.136-7 7v10h4V12z"/><path d="M6.5 18h5v1h-5zm10 0h5v1h-5z"/></g></svg>'},26130:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><path fill="currentColor" d="M2 9.75a1.5 1.5 0 0 0-1.5 1.5v5.5a1.5 1.5 0 0 0 1.5 1.5h24a1.5 1.5 0 0 0 1.5-1.5v-5.5a1.5 1.5 0 0 0-1.5-1.5zm0 1h3v2.5h1v-2.5h3.25v3.9h1v-3.9h3.25v2.5h1v-2.5h3.25v3.9h1v-3.9H22v2.5h1v-2.5h3a.5.5 0 0 1 .5.5v5.5a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-5.5a.5.5 0 0 1 .5-.5z" transform="rotate(-45 14 14)"/></svg>'},59204:e=>{
|
||||
e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><g fill="currentColor"><path fill-rule="nonzero" d="M14 18.634l-.307-.239-7.37-5.73-2.137-1.665 9.814-7.633 9.816 7.634-.509.394-1.639 1.269-7.667 5.969zm7.054-6.759l1.131-.876-8.184-6.366-8.186 6.367 1.123.875 7.063 5.491 7.054-5.492z"/><path d="M7 14.5l-1 .57 8 6.43 8-6.5-1-.5-7 5.5z"/><path d="M7 17.5l-1 .57 8 6.43 8-6.5-1-.5-7 5.5z"/></g></svg>'},53874:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" d="M4.05 14a9.95 9.95 0 1 1 19.9 0 9.95 9.95 0 0 1-19.9 0ZM14 3a11 11 0 1 0 0 22 11 11 0 0 0 0-22Zm-3 13.03a.5.5 0 0 1 .64.3 2.5 2.5 0 0 0 4.72 0 .5.5 0 0 1 .94.34 3.5 3.5 0 0 1-6.6 0 .5.5 0 0 1 .3-.64Zm.5-4.53a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm5 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>'},27215:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M7 4h14a3 3 0 0 1 3 3v11c0 .34-.03.67-.08 1H20.3c-1.28 0-2.31.97-2.31 2.24V24H7a3 3 0 0 1-3-3V7a3 3 0 0 1 3-3Zm12 19.92A6 6 0 0 0 23.66 20H20.3c-.77 0-1.31.48-1.31 1.24v2.68ZM3 7a4 4 0 0 1 4-4h14a4 4 0 0 1 4 4v11a7 7 0 0 1-7 7H7a4 4 0 0 1-4-4V7Zm8 9.03a.5.5 0 0 1 .64.3 2.5 2.5 0 0 0 4.72 0 .5.5 0 0 1 .94.34 3.5 3.5 0 0 1-6.6 0 .5.5 0 0 1 .3-.64Zm.5-4.53a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm5 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/></svg>'},5454:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" fill-rule="nonzero" d="M14 5a7 7 0 0 0-7 7v3h4v-3a3 3 0 1 1 6 0v3h4v-3a7 7 0 0 0-7-7zm7 11h-4v3h4v-3zm-10 0H7v3h4v-3zm-5-4a8 8 0 1 1 16 0v8h-6v-8a2 2 0 1 0-4 0v8H6v-8zm3.293 11.294l-1.222-2.037.858-.514 1.777 2.963-2 1 1.223 2.037-.858.514-1.778-2.963 2-1zm9.778-2.551l.858.514-1.223 2.037 2 1-1.777 2.963-.858-.514 1.223-2.037-2-1 1.777-2.963z"/></svg>'},30934:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><g fill="currentColor"><path fill-rule="nonzero" d="M15.039 5.969l-.019-.019-2.828 2.828.707.707 2.474-2.474c1.367-1.367 3.582-1.367 4.949 0s1.367 3.582 0 4.949l-2.474 2.474.707.707 2.828-2.828-.019-.019c1.415-1.767 1.304-4.352-.334-5.99-1.638-1.638-4.224-1.749-5.99-.334zM5.97 15.038l-.019-.019 2.828-2.828.707.707-2.475 2.475c-1.367 1.367-1.367 3.582 0 4.949s3.582 1.367 4.949 0l2.474-2.474.707.707-2.828 2.828-.019-.019c-1.767 1.415-4.352 1.304-5.99-.334-1.638-1.638-1.749-4.224-.334-5.99z"/><path d="M10.485 16.141l5.656-5.656.707.707-5.656 5.656z"/></g></svg>'},49604:e=>{
|
||||
e.exports='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M19.76 6.07l-.7.7a13.4 13.4 0 011.93 2.47c.19.3.33.55.42.72l.03.04-.03.04a15 15 0 01-2.09 2.9c-1.47 1.6-3.6 3.12-6.32 3.12-.98 0-1.88-.2-2.7-.52l-.77.76c1.03.47 2.18.76 3.47.76 3.12 0 5.5-1.75 7.06-3.44a16 16 0 002.38-3.38v-.02h.01L22 10l.45.22.1-.22-.1-.22L22 10l.45-.22-.01-.02a5.1 5.1 0 00-.15-.28 16 16 0 00-2.53-3.41zM6.24 13.93l.7-.7-.27-.29a15 15 0 01-2.08-2.9L4.56 10l.03-.04a15 15 0 012.09-2.9c1.47-1.6 3.6-3.12 6.32-3.12.98 0 1.88.2 2.7.52l.77-.76A8.32 8.32 0 0013 2.94c-3.12 0-5.5 1.75-7.06 3.44a16 16 0 00-2.38 3.38v.02h-.01L4 10l-.45-.22-.1.22.1.22L4 10l-.45.22.01.02a5.5 5.5 0 00.15.28 16 16 0 002.53 3.41zm6.09-.43a3.6 3.6 0 004.24-4.24l-.93.93a2.6 2.6 0 01-2.36 2.36l-.95.95zm-1.97-3.69l-.93.93a3.6 3.6 0 014.24-4.24l-.95.95a2.6 2.6 0 00-2.36 2.36zm11.29 7.84l-.8.79a1.5 1.5 0 000 2.12l.59.59a1.5 1.5 0 002.12 0l1.8-1.8-.71-.7-1.8 1.79a.5.5 0 01-.7 0l-.59-.59a.5.5 0 010-.7l.8-.8-.71-.7zm-5.5 3.5l.35.35-.35-.35.01-.02.02-.02.02-.02a4.68 4.68 0 01.65-.5c.4-.27 1-.59 1.65-.59.66 0 1.28.33 1.73.77.44.45.77 1.07.77 1.73a2.5 2.5 0 01-.77 1.73 2.5 2.5 0 01-1.73.77h-4a.5.5 0 01-.42-.78l1-1.5 1-1.5a.5.5 0 01.07-.07zm.74.67a3.46 3.46 0 01.51-.4c.35-.24.75-.42 1.1-.42.34 0 .72.17 1.02.48.3.3.48.68.48 1.02 0 .34-.17.72-.48 1.02-.3.3-.68.48-1.02.48h-3.07l.49-.72.97-1.46zM21.2 2.5L5.5 18.2l-.7-.7L20.5 1.8l.7.7z"/></svg>'},34491:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><path fill="currentColor" d="M16.47 3.7A8.32 8.32 0 0013 2.94c-3.12 0-5.5 1.75-7.06 3.44a16 16 0 00-2.38 3.38v.02h-.01L4 10l-.45-.22-.1.22.1.22L4 10l-.45.22.01.02a5.5 5.5 0 00.15.28 16 16 0 002.53 3.41l.7-.7-.27-.29a15 15 0 01-2.08-2.9L4.56 10l.03-.04a15 15 0 012.09-2.9c1.47-1.6 3.6-3.12 6.32-3.12.98 0 1.88.2 2.7.52l.77-.76zm-7.04 7.04l.93-.93a2.6 2.6 0 012.36-2.36l.95-.95a3.6 3.6 0 00-4.24 4.24zm.1 5.56c1.03.47 2.18.76 3.47.76 3.12 0 5.5-1.75 7.06-3.44a16 16 0 002.38-3.38v-.02h.01L22 10l.45.22.1-.22-.1-.22L22 10l.45-.22-.01-.02-.02-.03-.01-.03a9.5 9.5 0 00-.57-1 16 16 0 00-2.08-2.63l-.7.7.27.29a15.01 15.01 0 012.08 2.9l.03.04-.03.04a15 15 0 01-2.09 2.9c-1.47 1.6-3.6 3.12-6.32 3.12-.98 0-1.88-.2-2.7-.52l-.77.76zm2.8-2.8a3.6 3.6 0 004.24-4.24l-.93.93a2.6 2.6 0 01-2.36 2.36l-.95.95zm7.9 3.73c-.12.12-.23.35-.23.77v2h1v1h-1v2c0 .58-.14 1.1-.52 1.48-.38.38-.9.52-1.48.52s-1.1-.14-1.48-.52c-.38-.38-.52-.9-.52-1.48h1c0 .42.1.65.23.77.12.12.35.23.77.23.42 0 .65-.1.77-.23.12-.12.23-.35.23-.77v-2h-1v-1h1v-2c0-.58.14-1.1.52-1.48.38-.38.9-.52 1.48-.52s1.1.14 1.48.52c.38.38.52.9.52 1.48h-1c0-.42-.1-.65-.23-.77-.12-.12-.35-.23-.77-.23-.42 0-.65.1-.77.23zm2.56 6.27l-1.14-1.15.7-.7 1.15 1.14 1.15-1.14.7.7-1.14 1.15 1.14 1.15-.7.7-1.15-1.14-1.15 1.14-.7-.7 1.14-1.15zM21.2 2.5L5.5 18.2l-.7-.7L20.5 1.8l.7.7z"/></svg>'},8099:e=>{
|
||||
e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" d="M5.5 18.2L21.2 2.5l-.7-.7L4.8 17.5l.7.7zM19.05 6.78l.71-.7a14.26 14.26 0 0 1 2.08 2.64 14.26 14.26 0 0 1 .6 1.05v.02h.01L22 10l.45.22-.01.02a5.18 5.18 0 0 1-.15.28 16 16 0 0 1-2.23 3.1c-1.56 1.69-3.94 3.44-7.06 3.44-1.29 0-2.44-.3-3.47-.76l.76-.76c.83.32 1.73.52 2.71.52 2.73 0 4.85-1.53 6.33-3.12a15.01 15.01 0 0 0 2.08-2.9l.03-.04-.03-.04a15 15 0 0 0-2.36-3.18zM22 10l.45-.22.1.22-.1.22L22 10zM6.94 13.23l-.7.7a14.24 14.24 0 0 1-2.08-2.64 14.28 14.28 0 0 1-.6-1.05v-.02h-.01L4 10l-.45-.22.01-.02a5.55 5.55 0 0 1 .15-.28 16 16 0 0 1 2.23-3.1C7.5 4.69 9.88 2.94 13 2.94c1.29 0 2.44.3 3.47.76l-.76.76A7.27 7.27 0 0 0 13 3.94c-2.73 0-4.85 1.53-6.33 3.12a15 15 0 0 0-2.08 2.9l-.03.04.03.04a15.01 15.01 0 0 0 2.36 3.18zM4 10l-.45.22-.1-.22.1-.22L4 10zm9 3.56c-.23 0-.46-.02-.67-.06l.95-.95a2.6 2.6 0 0 0 2.36-2.36l.93-.93a3.6 3.6 0 0 1-3.57 4.3zm-3.57-2.82l.93-.93a2.6 2.6 0 0 1 2.36-2.36l.95-.95a3.6 3.6 0 0 0-4.24 4.24zM17.5 21.9l3.28 2.18a.5.5 0 1 1-.56.84L17.5 23.1l-2.72 1.82a.5.5 0 1 1-.56-.84l3.28-2.18zM18.58 19.22a.5.5 0 0 1 .7-.14L22 20.9l2.72-1.82a.5.5 0 0 1 .56.84L22 22.1l-3.28-2.18a.5.5 0 0 1-.14-.7z"/></svg>'},86766:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M14 6a3 3 0 0 0-3 3v3h6V9a3 3 0 0 0-3-3zm4 6V9a4 4 0 0 0-8 0v3H8.5A2.5 2.5 0 0 0 6 14.5v7A2.5 2.5 0 0 0 8.5 24h11a2.5 2.5 0 0 0 2.5-2.5v-7a2.5 2.5 0 0 0-2.5-2.5H18zm-5 5a1 1 0 1 1 2 0v2a1 1 0 1 1-2 0v-2zm-6-2.5c0-.83.67-1.5 1.5-1.5h11c.83 0 1.5.67 1.5 1.5v7c0 .83-.67 1.5-1.5 1.5h-11A1.5 1.5 0 0 1 7 21.5v-7z"/></svg>'},97941:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M14 6a3 3 0 0 0-3 3v3h8.5a2.5 2.5 0 0 1 2.5 2.5v7a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 6 21.5v-7A2.5 2.5 0 0 1 8.5 12H10V9a4 4 0 0 1 8 0h-1a3 3 0 0 0-3-3zm-1 11a1 1 0 1 1 2 0v2a1 1 0 1 1-2 0v-2zm-6-2.5c0-.83.67-1.5 1.5-1.5h11c.83 0 1.5.67 1.5 1.5v7c0 .83-.67 1.5-1.5 1.5h-11A1.5 1.5 0 0 1 7 21.5v-7z"/></svg>'},1607:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M4.56 14a10.05 10.05 0 00.52.91c.41.69 1.04 1.6 1.85 2.5C8.58 19.25 10.95 21 14 21c3.05 0 5.42-1.76 7.07-3.58A17.18 17.18 0 0023.44 14a9.47 9.47 0 00-.52-.91c-.41-.69-1.04-1.6-1.85-2.5C19.42 8.75 17.05 7 14 7c-3.05 0-5.42 1.76-7.07 3.58A17.18 17.18 0 004.56 14zM24 14l.45-.21-.01-.03a7.03 7.03 0 00-.16-.32c-.11-.2-.28-.51-.5-.87-.44-.72-1.1-1.69-1.97-2.65C20.08 7.99 17.45 6 14 6c-3.45 0-6.08 2-7.8 3.92a18.18 18.18 0 00-2.64 3.84v.02h-.01L4 14l-.45-.21-.1.21.1.21L4 14l-.45.21.01.03a5.85 5.85 0 00.16.32c.11.2.28.51.5.87.44.72 1.1 1.69 1.97 2.65C7.92 20.01 10.55 22 14 22c3.45 0 6.08-2 7.8-3.92a18.18 18.18 0 002.64-3.84v-.02h.01L24 14zm0 0l.45.21.1-.21-.1-.21L24 14zm-10-3a3 3 0 100 6 3 3 0 000-6zm-4 3a4 4 0 118 0 4 4 0 01-8 0z"/></svg>'},14798:e=>{
|
||||
e.exports='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M5 10.76l-.41-.72-.03-.04.03-.04a15 15 0 012.09-2.9c1.47-1.6 3.6-3.12 6.32-3.12 2.73 0 4.85 1.53 6.33 3.12a15.01 15.01 0 012.08 2.9l.03.04-.03.04a15 15 0 01-2.09 2.9c-1.47 1.6-3.6 3.12-6.32 3.12-2.73 0-4.85-1.53-6.33-3.12a15 15 0 01-1.66-2.18zm17.45-.98L22 10l.45.22-.01.02a5.04 5.04 0 01-.15.28 16.01 16.01 0 01-2.23 3.1c-1.56 1.69-3.94 3.44-7.06 3.44-3.12 0-5.5-1.75-7.06-3.44a16 16 0 01-2.38-3.38v-.02h-.01L4 10l-.45-.22.01-.02a5.4 5.4 0 01.15-.28 16 16 0 012.23-3.1C7.5 4.69 9.88 2.94 13 2.94c3.12 0 5.5 1.75 7.06 3.44a16.01 16.01 0 012.38 3.38v.02h.01zM22 10l.45-.22.1.22-.1.22L22 10zM3.55 9.78L4 10l-.45.22-.1-.22.1-.22zm6.8.22A2.6 2.6 0 0113 7.44 2.6 2.6 0 0115.65 10 2.6 2.6 0 0113 12.56 2.6 2.6 0 0110.35 10zM13 6.44A3.6 3.6 0 009.35 10 3.6 3.6 0 0013 13.56c2 0 3.65-1.58 3.65-3.56A3.6 3.6 0 0013 6.44zm7.85 12l.8-.8.7.71-.79.8a.5.5 0 000 .7l.59.59c.2.2.5.2.7 0l1.8-1.8.7.71-1.79 1.8a1.5 1.5 0 01-2.12 0l-.59-.59a1.5 1.5 0 010-2.12zM16.5 21.5l-.35-.35a.5.5 0 00-.07.07l-1 1.5-1 1.5a.5.5 0 00.42.78h4a2.5 2.5 0 001.73-.77A2.5 2.5 0 0021 22.5a2.5 2.5 0 00-.77-1.73A2.5 2.5 0 0018.5 20a3.1 3.1 0 00-1.65.58 5.28 5.28 0 00-.69.55v.01h-.01l.35.36zm.39.32l-.97 1.46-.49.72h3.07c.34 0 .72-.17 1.02-.48.3-.3.48-.68.48-1.02 0-.34-.17-.72-.48-1.02-.3-.3-.68-.48-1.02-.48-.35 0-.75.18-1.1.42a4.27 4.27 0 00-.51.4z"/></svg>'},43381:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M5 10.76a13.27 13.27 0 01-.41-.72L4.56 10l.03-.04a15 15 0 012.08-2.9c1.48-1.6 3.6-3.12 6.33-3.12s4.85 1.53 6.33 3.12a15.01 15.01 0 012.08 2.9l.03.04-.03.04a15 15 0 01-2.08 2.9c-1.48 1.6-3.6 3.12-6.33 3.12s-4.85-1.53-6.33-3.12a15 15 0 01-1.66-2.18zm17.45-.98L22 10l.45.22-.01.02a14.3 14.3 0 01-.6 1.05c-.4.64-1 1.48-1.78 2.33-1.56 1.7-3.94 3.44-7.06 3.44s-5.5-1.75-7.06-3.44a16 16 0 01-2.23-3.1 9.39 9.39 0 01-.15-.28v-.02h-.01L4 10l-.45-.22.01-.02a5.59 5.59 0 01.15-.28 16 16 0 012.23-3.1C7.5 4.69 9.87 2.94 13 2.94c3.12 0 5.5 1.75 7.06 3.44a16 16 0 012.23 3.1 9.5 9.5 0 01.15.28v.01l.01.01zM22 10l.45-.22.1.22-.1.22L22 10zM3.55 9.78L4 10l-.45.22-.1-.22.1-.22zm6.8.22A2.6 2.6 0 0113 7.44 2.6 2.6 0 0115.65 10 2.6 2.6 0 0113 12.56 2.6 2.6 0 0110.35 10zM13 6.44A3.6 3.6 0 009.35 10c0 1.98 1.65 3.56 3.65 3.56s3.65-1.58 3.65-3.56A3.6 3.6 0 0013 6.44zM20 18c0-.42.1-.65.23-.77.12-.13.35-.23.77-.23.42 0 .65.1.77.23.13.12.23.35.23.77h1c0-.58-.14-1.1-.52-1.48-.38-.38-.9-.52-1.48-.52s-1.1.14-1.48.52c-.37.38-.52.9-.52 1.48v2h-1v1h1v2c0 .42-.1.65-.23.77-.12.13-.35.23-.77.23-.42 0-.65-.1-.77-.23-.13-.12-.23-.35-.23-.77h-1c0 .58.14 1.1.52 1.48.38.37.9.52 1.48.52s1.1-.14 1.48-.52c.37-.38.52-.9.52-1.48v-2h1v-1h-1v-2zm1.65 4.35l1.14 1.15-1.14 1.15.7.7 1.15-1.14 1.15 1.14.7-.7-1.14-1.15 1.14-1.15-.7-.7-1.15 1.14-1.15-1.14-.7.7z"/></svg>'},56073:e=>{
|
||||
e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28"><path fill="currentColor" fill-rule="evenodd" d="M4.5 10a8.46 8.46 0 0 0 .46.8c.38.6.94 1.4 1.68 2.19 1.48 1.6 3.62 3.13 6.36 3.13s4.88-1.53 6.36-3.13A15.07 15.07 0 0 0 21.5 10a7.41 7.41 0 0 0-.46-.8c-.38-.6-.94-1.4-1.68-2.19-1.48-1.6-3.62-3.13-6.36-3.13S8.12 5.4 6.64 7A15.07 15.07 0 0 0 4.5 10zM22 10l.41-.19-.4.19zm0 0l.41.19-.4-.19zm.41.19l.09-.19-.09-.19-.01-.02a6.86 6.86 0 0 0-.15-.28c-.1-.18-.25-.45-.45-.76-.4-.64-.99-1.48-1.77-2.32C18.47 4.74 16.11 3 13 3 9.89 3 7.53 4.74 5.97 6.43A15.94 15.94 0 0 0 3.6 9.79v.02h-.01L3.5 10l.09.19.01.02a6.59 6.59 0 0 0 .15.28c.1.18.25.45.45.76.4.64.99 1.48 1.77 2.32C7.53 15.26 9.89 17 13 17c3.11 0 5.47-1.74 7.03-3.43a15.94 15.94 0 0 0 2.37-3.36v-.02h.01zM4 10l-.41-.19.4.19zm9-2.63c-1.5 0-2.7 1.18-2.7 2.63s1.2 2.63 2.7 2.63c1.5 0 2.7-1.18 2.7-2.63S14.5 7.37 13 7.37zM9.4 10C9.4 8.07 11 6.5 13 6.5s3.6 1.57 3.6 3.5S15 13.5 13 13.5A3.55 3.55 0 0 1 9.4 10zm8.1 11.9l3.28 2.18a.5.5 0 1 1-.56.84L17.5 23.1l-2.72 1.82a.5.5 0 1 1-.56-.84l3.28-2.18zm1.78-2.82a.5.5 0 0 0-.56.84L22 22.1l3.28-2.18a.5.5 0 1 0-.56-.84L22 20.9l-2.72-1.82z"/></svg>'},28697:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28" fill="currentColor"><path d="M17.646 18.354l4 4 .708-.708-4-4z"/><path d="M12.5 21a8.5 8.5 0 1 1 0-17 8.5 8.5 0 0 1 0 17zm0-1a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z"/><path d="M9 13h7v-1H9z"/><path d="M13 16V9h-1v7z"/></svg>'},78120:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" width="28" height="28" fill="currentColor"><path d="M17.646 18.354l4 4 .708-.708-4-4z"/><path d="M12.5 21a8.5 8.5 0 1 1 0-17 8.5 8.5 0 0 1 0 17zm0-1a7.5 7.5 0 1 0 0-15 7.5 7.5 0 0 0 0 15z"/><path d="M9 13h7v-1H9z"/></svg>'},51894:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 16" width="10" height="16"><path d="M.6 1.4l1.4-1.4 8 8-8 8-1.4-1.4 6.389-6.532-6.389-6.668z"/></svg>'},72995:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18" fill="none"><path fill="currentColor" d="M9 1l2.35 4.76 5.26.77-3.8 3.7.9 5.24L9 13l-4.7 2.47.9-5.23-3.8-3.71 5.25-.77L9 1z"/></svg>'},89658:e=>{e.exports='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18" width="18" height="18" fill="none"><path stroke="currentColor" d="M9 2.13l1.903 3.855.116.236.26.038 4.255.618-3.079 3.001-.188.184.044.259.727 4.237-3.805-2L9 12.434l-.233.122-3.805 2.001.727-4.237.044-.26-.188-.183-3.079-3.001 4.255-.618.26-.038.116-.236L9 2.13z"/></svg>'}}]);
|
||||
@@ -1 +0,0 @@
|
||||
[data-theme=light]{--_0-18Pi:var(--color-cold-gray-900);--_1-18Pi:var(--color-white);--_2-18Pi:var(--color-cold-gray-150);--_3-18Pi:var(--color-tv-blue-200);--_4-18Pi:var(--color-cold-gray-150)}[data-theme=dark]{--_0-18Pi:var(--color-cold-gray-200);--_1-18Pi:var(--color-cold-gray-850);--_2-18Pi:var(--color-cold-gray-600);--_3-18Pi:var(--color-tv-blue-a700);--_4-18Pi:var(--color-cold-gray-750)}.button-Rc93kXa8{background-color:var(--_1-18Pi);border:none;border-radius:4px;color:var(--color-default-gray);font-size:12px;height:22px;padding-inline-end:8px;padding-inline-start:8px;white-space:nowrap}@media (any-hover:hover){.button-Rc93kXa8:hover{background-color:var(--_2-18Pi);color:var(--_0-18Pi)}}.button-Rc93kXa8.bordersVisible-Rc93kXa8{border:1px solid var(--_4-18Pi);padding:0 7px}.button-Rc93kXa8.selected-Rc93kXa8{background-color:var(--_3-18Pi);color:var(--_0-18Pi)}.button-Rc93kXa8+.button-Rc93kXa8{margin-inline-start:8px}.listOption-Rc93kXa8{--ui-lib-squareButton-background:var(--tv-color-list-item-button-background,var(--color-container-fill-tertiary-inverse));--ui-lib-squareButton-border-color:var(--color-border-primary-neutral-light);--ui-lib-squareButton-content-color:var(--color-content-secondary-neutral-bold)}.listOption-Rc93kXa8.selected-Rc93kXa8{--ui-lib-squareButton-background:var(--tv-color-selected-list-item-button-background,var(--color-container-fill-primary-neutral-extra-bold));--ui-lib-squareButton-border-color:var(--tv-color-selected-list-item-button-background,var(--color-container-fill-primary-neutral-extra-bold));--ui-lib-squareButton-content-color:var(--tv-color-selected-list-item-button-text,var(--color-content-secondary-inverse))}@media (any-hover:hover){.listOption-Rc93kXa8:hover{--ui-lib-squareButton-background:var(--color-container-fill-primary-neutral-bold);--ui-lib-squareButton-border-color:var(--color-container-fill-primary-neutral-bold)}}.listOption-Rc93kXa8:active{--ui-lib-squareButton-background:var(--color-container-fill-primary-neutral-medium);--ui-lib-squareButton-border-color:var(--color-container-fill-primary-neutral-medium)}.listOption-Rc93kXa8:active{--ui-lib-squareButton-content-color:var(--color-content-secondary-inverse)}@media (any-hover:hover){.listOption-Rc93kXa8:hover{--ui-lib-squareButton-content-color:var(--color-content-secondary-inverse)}}[data-theme=light]{--_0-bOll:var(--color-cold-gray-900);--_1-bOll:var(--color-tv-blue-500);--_2-bOll:var(--color-white)}[data-theme=dark]{--_0-bOll:var(--color-cold-gray-200);--_1-bOll:var(--color-tv-blue-500);--_2-bOll:var(--color-cold-gray-200)}.wrap-oc7l8ZQg{align-items:center;display:flex;gap:8px;height:52px}.header-oc7l8ZQg{color:var(--color-default-gray);font-size:11px;line-height:16px;margin-top:2px;padding:8px 20px;text-transform:uppercase}.item-oc7l8ZQg{box-sizing:border-box;color:var(--_0-bOll);font-size:16px;height:40px;line-height:24px;padding:10px 16px}.item-oc7l8ZQg:active{background-color:var(--_1-bOll);color:var(--_2-bOll)}[data-theme=light]{--_0-Tw47:var(--color-cold-gray-900)}[data-theme=dark]{--_0-Tw47:var(--color-cold-gray-200)}.scrollable-sXALjK1u{flex:1 1 auto;height:100%;min-height:145px;overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch}@media (max-height:290px){.scrollable-sXALjK1u{min-height:auto}}@supports (-moz-appearance:none){.scrollable-sXALjK1u{scrollbar-color:var(--tv-color-scrollbar-thumb-background,var(--color-scroll-bg)) transparent;scrollbar-width:thin}}.scrollable-sXALjK1u::-webkit-scrollbar{height:5px;width:5px}.scrollable-sXALjK1u::-webkit-scrollbar-thumb{background-clip:content-box;background-color:var(--tv-color-scrollbar-thumb-background,var(--color-scroll-bg));border:1px solid transparent;border-radius:3px}.scrollable-sXALjK1u::-webkit-scrollbar-track{background-color:transparent;border-radius:3px}.scrollable-sXALjK1u::-webkit-scrollbar-corner{display:none}.spinnerWrap-sXALjK1u{height:100%;width:100%}.item-sXALjK1u:first-child{margin-top:6px}.item-sXALjK1u:last-child{margin-bottom:6px}.heading-sXALjK1u{color:var(--color-default-gray);font-size:11px;line-height:16px;padding-block:16px 8px;padding-inline:20px 20px;text-transform:uppercase}.checkboxWrap-sXALjK1u{padding-inline-end:8px}.checkbox-sXALjK1u{align-items:baseline;display:flex;height:28px;justify-content:center;padding:0;width:28px}.emptyState-sXALjK1u{align-items:center;display:flex;flex-flow:column;height:100%;justify-content:center}.emptyState-sXALjK1u .image-sXALjK1u{align-items:center;display:flex;height:120px}.emptyState-sXALjK1u .text-sXALjK1u{color:var(--_0-Tw47);font-size:16px;line-height:24px;margin-top:8px}.dialog-IKuIIugL{height:565px;overflow:hidden;width:100%}.tabletDialog-IKuIIugL{max-width:560px}.desktopDialog-IKuIIugL{max-width:840px;min-width:719px;width:100%}@media (max-width:768px){.desktopDialog-IKuIIugL{max-width:640px;min-width:480px}}@media (max-width:519px){.desktopDialog-IKuIIugL{max-width:479px;min-width:380px}}.label-lVJKBKVk{align-items:center;display:flex;gap:8px}
|
||||
@@ -1 +0,0 @@
|
||||
[data-theme=light]{--_0-18Pi:var(--color-cold-gray-900);--_1-18Pi:var(--color-white);--_2-18Pi:var(--color-cold-gray-150);--_3-18Pi:var(--color-tv-blue-200);--_4-18Pi:var(--color-cold-gray-150)}[data-theme=dark]{--_0-18Pi:var(--color-cold-gray-200);--_1-18Pi:var(--color-cold-gray-850);--_2-18Pi:var(--color-cold-gray-600);--_3-18Pi:var(--color-tv-blue-a700);--_4-18Pi:var(--color-cold-gray-750)}.button-Rc93kXa8{background-color:var(--_1-18Pi);border:none;border-radius:4px;color:var(--color-default-gray);font-size:12px;height:22px;padding-inline-end:8px;padding-inline-start:8px;white-space:nowrap}@media (any-hover:hover){.button-Rc93kXa8:hover{background-color:var(--_2-18Pi);color:var(--_0-18Pi)}}.button-Rc93kXa8.bordersVisible-Rc93kXa8{border:1px solid var(--_4-18Pi);padding:0 7px}.button-Rc93kXa8.selected-Rc93kXa8{background-color:var(--_3-18Pi);color:var(--_0-18Pi)}.button-Rc93kXa8+.button-Rc93kXa8{margin-inline-start:8px}.listOption-Rc93kXa8{--ui-lib-squareButton-background:var(--tv-color-list-item-button-background,var(--color-container-fill-tertiary-inverse));--ui-lib-squareButton-border-color:var(--color-border-primary-neutral-light);--ui-lib-squareButton-content-color:var(--color-content-secondary-neutral-bold)}.listOption-Rc93kXa8.selected-Rc93kXa8{--ui-lib-squareButton-background:var(--tv-color-selected-list-item-button-background,var(--color-container-fill-primary-neutral-extra-bold));--ui-lib-squareButton-border-color:var(--tv-color-selected-list-item-button-background,var(--color-container-fill-primary-neutral-extra-bold));--ui-lib-squareButton-content-color:var(--tv-color-selected-list-item-button-text,var(--color-content-secondary-inverse))}@media (any-hover:hover){.listOption-Rc93kXa8:hover{--ui-lib-squareButton-background:var(--color-container-fill-primary-neutral-bold);--ui-lib-squareButton-border-color:var(--color-container-fill-primary-neutral-bold)}}.listOption-Rc93kXa8:active{--ui-lib-squareButton-background:var(--color-container-fill-primary-neutral-medium);--ui-lib-squareButton-border-color:var(--color-container-fill-primary-neutral-medium)}.listOption-Rc93kXa8:active{--ui-lib-squareButton-content-color:var(--color-content-secondary-inverse)}@media (any-hover:hover){.listOption-Rc93kXa8:hover{--ui-lib-squareButton-content-color:var(--color-content-secondary-inverse)}}[data-theme=light]{--_0-bOll:var(--color-cold-gray-900);--_1-bOll:var(--color-tv-blue-500);--_2-bOll:var(--color-white)}[data-theme=dark]{--_0-bOll:var(--color-cold-gray-200);--_1-bOll:var(--color-tv-blue-500);--_2-bOll:var(--color-cold-gray-200)}.wrap-oc7l8ZQg{align-items:center;display:flex;gap:8px;height:52px}.header-oc7l8ZQg{color:var(--color-default-gray);font-size:11px;line-height:16px;margin-top:2px;padding:8px 20px;text-transform:uppercase}.item-oc7l8ZQg{box-sizing:border-box;color:var(--_0-bOll);font-size:16px;height:40px;line-height:24px;padding:10px 16px}.item-oc7l8ZQg:active{background-color:var(--_1-bOll);color:var(--_2-bOll)}[data-theme=light]{--_0-Tw47:var(--color-cold-gray-900)}[data-theme=dark]{--_0-Tw47:var(--color-cold-gray-200)}.scrollable-sXALjK1u{flex:1 1 auto;height:100%;min-height:145px;overflow-x:hidden;overflow-y:auto;-webkit-overflow-scrolling:touch}@media (max-height:290px){.scrollable-sXALjK1u{min-height:auto}}@supports (-moz-appearance:none){.scrollable-sXALjK1u{scrollbar-color:var(--tv-color-scrollbar-thumb-background,var(--color-scroll-bg)) transparent;scrollbar-width:thin}}.scrollable-sXALjK1u::-webkit-scrollbar{height:5px;width:5px}.scrollable-sXALjK1u::-webkit-scrollbar-thumb{background-clip:content-box;background-color:var(--tv-color-scrollbar-thumb-background,var(--color-scroll-bg));border:1px solid transparent;border-radius:3px}.scrollable-sXALjK1u::-webkit-scrollbar-track{background-color:transparent;border-radius:3px}.scrollable-sXALjK1u::-webkit-scrollbar-corner{display:none}.spinnerWrap-sXALjK1u{height:100%;width:100%}.item-sXALjK1u:first-child{margin-top:6px}.item-sXALjK1u:last-child{margin-bottom:6px}.heading-sXALjK1u{color:var(--color-default-gray);font-size:11px;line-height:16px;padding-block:16px 8px;padding-inline:20px 20px;text-transform:uppercase}.checkboxWrap-sXALjK1u{padding-inline-end:8px}.checkbox-sXALjK1u{align-items:baseline;display:flex;height:28px;justify-content:center;padding:0;width:28px}.emptyState-sXALjK1u{align-items:center;display:flex;flex-flow:column;height:100%;justify-content:center}.emptyState-sXALjK1u .image-sXALjK1u{align-items:center;display:flex;height:120px}.emptyState-sXALjK1u .text-sXALjK1u{color:var(--_0-Tw47);font-size:16px;line-height:24px;margin-top:8px}.dialog-IKuIIugL{height:565px;overflow:hidden;width:100%}.tabletDialog-IKuIIugL{max-width:560px}.desktopDialog-IKuIIugL{max-width:840px;min-width:719px;width:100%}@media (max-width:768px){.desktopDialog-IKuIIugL{max-width:640px;min-width:480px}}@media (max-width:519px){.desktopDialog-IKuIIugL{max-width:479px;min-width:380px}}.label-lVJKBKVk{align-items:center;display:flex;gap:8px}
|
||||
@@ -1 +0,0 @@
|
||||
.button-KTgbfaP5{height:38px;justify-content:center;width:52px}
|
||||
@@ -1 +0,0 @@
|
||||
.button-KTgbfaP5{height:38px;justify-content:center;width:52px}
|
||||
@@ -1,6 +0,0 @@
|
||||
"use strict";(self.webpackChunktradingview=self.webpackChunktradingview||[]).push([[144,9974],{7955:(e,t,i)=>{i.d(t,{inplaceEditHandlers:()=>o});var n=i(83077);function o(e){const t=(t,i)=>{i.sourceWasSelected&&e(t)};return{areaName:n.AreaName.Text,executeDefaultAction:{doubleClickHandler:!0,doubleTapHandler:!0},clickHandler:t,tapHandler:t}}},7919:(e,t,i)=>{i.d(t,{InplaceTextLineSourcePaneView:()=>u});var n=i(85842),o=i(91599),s=i(82347),r=i(89772),a=i(41928),d=i(83077),l=i(28031),c=i(29968);const h=o.t(null,void 0,i(3443));class u extends a.LineSourcePaneView{constructor(e,t,i,n,o){super(e,t,o),this._textInfo=new r.WatchedObject({}),this._isTextEditModeActivated=!1,this._textWasEdited=!1,this._showTextEditor=i,this._hideTextEditor=n,this._editableTextSpawn=this._source.editableText().spawn(),this._editableTextSpawn.subscribe((()=>this._updateTextWasEditable()))}destroy(){this._editableTextSpawn.destroy()}setSelectionRange(e){this._selectionRange=e}closeTextEditor(){this._closeTextEditorImpl()}_closeTextEditorImpl(e){this._textWasEdited=!1,this._isTextEditModeActivated=!1,this._hideTextEditor?.(e)}_placeHolderMode(e){return!this._isTextEditMode()&&this._model.hoveredSource()===this._source&&0===this._model.hoveredSourceOrigin()&&(!e||this._model.lastHittestData()?.areaName!==d.AreaName.AnchorPoint)&&!(0,c.lastMouseOrTouchEventInfo)().isTouch&&!this._source.editableTextProperties().text.value()&&this._model.selection().isSelected(this._source)}_updateTextWasEditable(){this._textWasEdited=!0}_textCursorType(){return this._model.selection().isSelected(this._source)&&!this._model.sourcesBeingMoved().includes(this._source)?l.PaneCursorType.Text:void 0}_updateInplaceText(e){this._textInfo.setValue(e),this._model.selection().isSelected(this._source)||this.closeTextEditor();const t=this._source.textEditingEl();t&&this._activateEditMode(t)}_tryActivateEditMode(e,t){const i=(0,n.ensureNotNull)(t.target instanceof HTMLElement?t.target.closest(".chart-gui-wrapper"):null);this._activateEditMode(i)}_isTextEditMode(){return this._isTextEditModeActivated}_isTextBeingEdited(){return this._textWasEdited}_textData(){return this._text()||(this._textWasEdited?"":h)}_textColor(){const e=this._source.editableTextProperties().textColor.value();return this._text()?e:(0,s.generateColor)(e,50,!0)}_inplaceTextHighlight(){const e=this._source.editableTextStyle();return this._selectionRange?{selectionHighlight:{start:this._selectionRange[0],end:this._selectionRange[1],color:(0,s.generateColor)(e.selectionColor,80,!0)}}:{}}_activateEditMode(e){this._showTextEditor?.((0,n.ensureNotNull)(this._getOwnerSource()),e,this._textInfo,h,this._closeTextEditorImpl.bind(this)),this._isTextEditModeActivated=!0}_text(){return this._isTextEditMode()?this._source.editableText().value():this._source.editableTextProperties().text.value()}}},63212:(e,t,i)=>{i.d(t,{InplaceTextLineDataSource:()=>S,InplaceTextUndoCommand:()=>m})
|
||||
;var n=i(83991),o=i(85842),s=i(57415),r=i(11284),a=i(55482),d=i(91599),l=i(72769),c=i(68657),h=i(89659),u=i(1479),_=i(82014),p=i(9840),g=i(34773);const x={selectionColor:(0,r.getHexColorByName)("color-tv-blue-500"),cursorColor:(0,r.getHexColorByName)("color-black")},T={selectionColor:(0,r.getHexColorByName)("color-white"),cursorColor:(0,r.getHexColorByName)("color-white")};var P;!function(e){e[e.TextEditingJustFinishedTime=100]="TextEditingJustFinishedTime"}(P||(P={}));class m extends u.UndoCommand{constructor(e,t,n,o){super(new l.TranslatedString("change {title} text",d.t(null,void 0,i(58899))).format({title:new l.TranslatedString(t.name(),t.translatedType())}),!0,!g.lineToolsDoNotAffectChartInvalidation),this._sourceId=t.id(),this._model=e,this._oldValue=n,this._newValue=o}redo(){const e=this._source();this._textProperty(e).setValue(this._newValue)}undo(){const e=this._source();this._textProperty(e).setValue(this._oldValue)}_textProperty(e){return e.editableTextProperties().text}_source(){return(0,o.ensureNotNull)(this._model.dataSourceForId(this._sourceId))}}class S extends _.LineDataSource{constructor(e,t,n,o){super(e,t,n,o),this._container=null,this._activeEditingOwnerSource=null,this._editableText=new h.WatchedValue(""),this._activateTextEditingEl=null,this._paneView=null,this._selectionData={},this._cursorPaneView=null,this._cursorPosition=null,this._editingOnCreation=!1,this._editingActivationTime=null,this._editingDeactivationTime=0,this._editableText.subscribe((()=>{this.updateAllViewsAndRedraw((0,p.sourceChangeEvent)(this.id()))})),this._isDarkBackground=(0,c.combine)(((e,t)=>{if(null===t)return this._model.dark().value();const i=(0,a.blendRgba)((0,a.parseRgba)(e),(0,a.parseRgba)(t));return"black"===(0,a.rgbToBlackWhiteString)([i[0],i[1],i[2]],150)}),this._model.backgroundColor().spawnOwnership(),this._createDataSourceBackgroundColorWV()),Promise.all([i.e(8263),i.e(144),i.e(4073),i.e(1912),i.e(1495)]).then(i.bind(i,16630)).then((t=>{this._cursorPaneView=new t.InplaceTextCursorPaneView(this,e),this._additionalCursorDataGetters&&(this._cursorPaneView.setAdditionalCursorData(...this._additionalCursorDataGetters),null!==this._cursorPosition&&(this._cursorPaneView.setCursorPosition(this._cursorPosition),e.updateSource(this)))}))}destroy(){this._isDarkBackground.destroy(),this._editableText.unsubscribe(),this._closeTextEditor(),super.destroy()}editableTextStyle(){return{...this._isDarkBackground.value()?T:x}}removeIfEditableTextIsEmpty(){return!1}activateEditingOnCreation(){return!1}topPaneViews(e){return this._activeEditingOwnerSource&&e.hasDataSource(this._activeEditingOwnerSource)&&!window.TradingView.printing&&this._cursorPaneView?(this._cursorPaneView.update((0,p.sourceChangeEvent)(this.id())),[this._cursorPaneView]):null}dataAndViewsReady(){return super.dataAndViewsReady()&&null!==this._cursorPaneView}editableText(){return this._editableText}textEditingEl(){return this._activateTextEditingEl}activateTextEditingOn(e,t){this._activateTextEditingEl=e,this._editingOnCreation=!!t,
|
||||
this._editingActivationTime=performance.now(),this.updateAllViewsAndRedraw((0,p.sourceChangeEvent)(this.id()))}deactivateTextEditing(){this._closeTextEditor()}textEditingActivationTime(){return this._editingActivationTime}textEditingJustFinished(){return performance.now()-this._editingDeactivationTime<100}setAdditionalCursorData(e,t){this._cursorPaneView?this._cursorPaneView.setAdditionalCursorData(e,t):this._additionalCursorDataGetters=[e,t]}_updateAllPaneViews(e){super._updateAllPaneViews(e),this._cursorPaneView?.update(e)}async _openTextEditor(e,t,n,r,a){if(null!==this._container)return;null===this._editingActivationTime&&(this._editingActivationTime=performance.now()),this._activateTextEditingEl=null,this._cursorPosition=null,this._container=document.createElement("div"),this._container.style.position="absolute",this._container.style.top="0",this._container.style.bottom="0",this._container.style.left="0",this._container.style.right="0",this._container.style.overflow="hidden",this._container.style.pointerEvents="none",t.appendChild(this._container);const{updateChartEditorText:d,closeChartEditorText:l}=await Promise.all([i.e(7922),i.e(9365),i.e(269)]).then(i.bind(i,5443));if(null===this._container||this._isDestroyed)return;this._activeEditingOwnerSource=e,this._closeChartEditorText=l;const{text:c,textColor:h,wordWrap:u}=this.editableTextProperties(),{forbidLineBreaks:_,maxLength:g}=this.editableTextStyle();this._editableText.setValue(c.value());const x=this.isFixed()?(0,o.ensureDefined)(this.fixedPoint(e)):(0,o.ensureNotNull)(this.pointToScreenPoint(this._points[0],e)),T={position:(0,s.point)(x.x,x.y),textInfo:n,placeholder:r,text:this._editableText,textColor:h,wordWrap:u,forbidLineBreaks:_,maxLength:g,onClose:a,onSelectionChange:this._onSelectionChange.bind(this),onContextMenu:this.onContextMenu?this.onContextMenu.bind(this):void 0};d(this._container,T),this.updateAllViewsAndRedraw((0,p.sourceChangeEvent)(this.id()))}_closeTextEditor(e){null===this._container||this._isDestroyed||(this._editingActivationTime=null,this._editingDeactivationTime=performance.now(),this._saveEditedText(),this._editingOnCreation=!1,this._onSelectionChange(),this._closeChartEditorText?.(this._container),this._closeChartEditorText=void 0,this._container.remove(),this._container=null,this._activeEditingOwnerSource=null,this.updateAllViewsAndRedraw((0,p.sourceChangeEvent)(this.id())))}_saveEditedText(){const e=this.editableTextProperties().text.value(),t=this._editableText.value();e!==t&&(this._editingOnCreation&&this.editableTextProperties().text.setValue(t),this._model.undoModel().undoHistory().pushUndoCommand(this._changeEditableTextUndoCommand(e,t)))}_changeEditableTextUndoCommand(e,t){return new m(this._model,this,e,t)}_createDataSourceBackgroundColorWV(){return new h.WatchedValue(null).readonly().ownership()}_onSelectionChange(e){if(null===this._container)return;const t={};if(void 0!==e){const{start:i,end:n}=e;i===n?t.cursorPosition=i:t.selectionRange=[Math.min(i,n),Math.max(i,n)]}(0,
|
||||
n.default)(t,this._selectionData)||(this._selectionData=t,this._paneViews.forEach((e=>{e.forEach((e=>{"setSelectionRange"in e&&e.setSelectionRange(t.selectionRange)}))})),this._cursorPaneView?this._cursorPaneView.setCursorPosition(t.cursorPosition):this._cursorPosition=t.cursorPosition??null,this.updateAllViewsAndRedraw((0,p.sourceChangeEvent)(this.id())))}}},41928:(e,t,i)=>{i.d(t,{LineSourcePaneView:()=>x,anchorResizeCursorType:()=>p,createLineSourcePaneViewPoint:()=>g});var n=i(11284),o=i(85842),s=i(29968),r=i(83077),a=i(71367),d=i(8165),l=i(28031),c=i(61208);const h=n.colorsPalette["color-tv-blue-600"];var u,_;function p(e,t){const i=e.x-t.x,n=e.y-t.y;if(!Number.isFinite(i)||!Number.isFinite(n)||0===i&&0===n)return l.PaneCursorType.Default;let s=Math.atan2(n,i);return s<0&&(s+=2*Math.PI),s>=_.deg337_5||s<_.deg22_5||s>=_.deg157_5&&s<_.deg202_5?l.PaneCursorType.HorizontalResize:s>=_.deg22_5&&s<_.deg67_5||s>=_.deg202_5&&s<_.deg247_5?l.PaneCursorType.DiagonalNwSeResize:s>=_.deg67_5&&s<_.deg112_5||s>=_.deg247_5&&s<_.deg292_5?l.PaneCursorType.VerticalResize:s>=_.deg112_5&&s<_.deg157_5||s>=_.deg292_5&&s<_.deg337_5?l.PaneCursorType.DiagonalNeSwResize:void(0,o.assert)(!1,"unexpected angle")}function g(e,t){return e.pointIndex=t,e}!function(e){e[e.RegularAnchorRadius=6]="RegularAnchorRadius",e[e.TouchAnchorRadius=13]="TouchAnchorRadius",e[e.RegularStrokeWidth=1]="RegularStrokeWidth",e[e.TouchStrokeWidth=3]="TouchStrokeWidth",e[e.RegularSelectedStrokeWidth=3]="RegularSelectedStrokeWidth",e[e.TouchSelectedStrokeWidth=0]="TouchSelectedStrokeWidth"}(u||(u={})),function(e){e[e.deg22_5=Math.PI/8]="deg22_5",e[e.deg67_5=3*Math.PI/8]="deg67_5",e[e.deg112_5=5*Math.PI/8]="deg112_5",e[e.deg157_5=7*Math.PI/8]="deg157_5",e[e.deg202_5=9*Math.PI/8]="deg202_5",e[e.deg247_5=11*Math.PI/8]="deg247_5",e[e.deg292_5=13*Math.PI/8]="deg292_5",e[e.deg337_5=15*Math.PI/8]="deg337_5"}(_||(_={}));class x{constructor(e,t,i){this._invalidated=!0,this._points=[],this._middlePoint=null,this._selectionRenderers=[],this._lineAnchorRenderers=[],this._source=e,this._model=t,this._ownerSource=i??null}priceToCoordinate(e){const t=this._getOwnerSource(),i=t?.priceScale();if(null==i)return null;const n=null!==t?t.firstValue():null;return null===n?null:i.priceToCoordinate(e,n)}anchorColor(){return h}isHoveredSource(){return this._source===this._model.hoveredSource()}isSelectedSource(){return this._model.selection().isSelected(this._source)}isBeingEdited(){return this._model.lineBeingEdited()===this._source}isEditMode(){return!this._model.isSnapshot()}areAnchorsVisible(){return(this.isHoveredSource()&&!this.isLocked()||this.isSelectedSource())&&this.isEditMode()}update(){this._invalidated=!0}isLocked(){return Boolean(this._source.isLocked&&this._source.isLocked())}addAnchors(e,t={}){let i=this._getPoints();this._model.lineBeingCreated()===this._source&&(i=i.slice(0,-1));const n=this._source.points(),o=i.map(((e,t)=>{const i=n[t],o=(0,d.lineSourcePaneViewPointToLineAnchorPoint)(e);return i&&(o.snappingPrice=i.price,o.snappingIndex=i.index),o}))
|
||||
;e.append(this.createLineAnchor({...t,points:o},0))}createLineAnchor(e,t){const i=e.points.map((e=>e.point)),n=this._getOwnerSource();if(this.isLocked()){const o=this._getSelectionRenderer(t);return o.setData({bgColors:this._lineAnchorColors(i),points:e.points,visible:this.areAnchorsVisible(),hittestResult:r.HitTarget.Regular,ownerSourceId:n?.id(),barSpacing:this._model.timeScale().barSpacing()}),o}const o=(0,s.lastMouseOrTouchEventInfo)().isTouch,a=this._getLineAnchorRenderer(t),d=this.isHoveredSource()?this._model.lastHittestData()?.pointIndex??null:null;return a.setData({...e,color:this.anchorColor(),backgroundColors:this._lineAnchorColors(i),hoveredPointIndex:d,linePointBeingEdited:this.isBeingEdited()?this._model.linePointBeingEdited():null,radius:this._anchorRadius(),strokeWidth:o?u.TouchStrokeWidth:u.RegularStrokeWidth,selected:this.isSelectedSource(),selectedStrokeWidth:o?u.TouchSelectedStrokeWidth:u.RegularSelectedStrokeWidth,visible:this.areAnchorsVisible(),clickHandler:e.clickHandler,ownerSourceId:n?.id()}),a}_getOwnerSource(){return this._ownerSource??this._source.ownerSource()}_anchorRadius(){return(0,s.lastMouseOrTouchEventInfo)().isTouch?u.TouchAnchorRadius:u.RegularAnchorRadius}_lineAnchorColors(e){const t=(0,o.ensureNotNull)(this._model.paneForSource(this._source)).height();return e.map((e=>this._model.backgroundColorAtYPercentFromTop(e.y/t)))}_updateImpl(e){this._points=[];this._model.timeScale().isEmpty()||this._validatePriceScale()&&(this._source.points().forEach(((e,t)=>{const i=this._source.pointToScreenPoint(e,this._ownerSource??void 0);i&&this._points.push(g(i,t))})),2===this._points.length&&(this._middlePoint=this._source.calcMiddlePoint(this._points[0],this._points[1])),this._invalidated=!1)}_validatePriceScale(){const e=this._getOwnerSource()?.priceScale();return null!=e&&!e.isEmpty()}_getSource(){return this._source}_getPoints(){return this._points}_getModel(){return this._model}_height(){const e=this._getOwnerSource()?.priceScale();return null!=e?e.height():0}_width(){return this._model.timeScale().width()}_needLabelExclusionPath(e,t){const i=this._source.properties().childs();return"middle"===(t??i.vertLabelsAlign.value())&&(0,c.needTextExclusionPath)(e)}_addAlertRenderer(e,t,i=this._source.properties().linecolor.value()){}_getAlertRenderer(e){return null}_getSelectionRenderer(e){for(;this._selectionRenderers.length<=e;)this._selectionRenderers.push(new a.SelectionRenderer);return this._selectionRenderers[e]}_getLineAnchorRenderer(e){for(;this._lineAnchorRenderers.length<=e;)this._lineAnchorRenderers.push(new d.LineAnchorRenderer);return this._lineAnchorRenderers[e]}}},8165:(e,t,i)=>{i.d(t,{LineAnchorRenderer:()=>T,lineSourcePaneViewPointToLineAnchorPoint:()=>P,lineSourcePaneViewPointToLineAnchorPoint2:()=>m,mapLineSourcePaneViewPointToLineAnchorPoint:()=>S});var n=i(57415),o=i(91069),s=i(85842),r=i(51946),a=i(7321),d=i(83077),l=i(28031),c=i(72244),h=i(57507);function u(e,t,i,n){const{point:o}=t,s=i+n/2;(0,r.drawRoundRect)(e,o.x-s,o.y-s,2*s,2*s,(i+n)/2),e.closePath(),e.lineWidth=n}
|
||||
function _(e,t,i,n){e.globalAlpha=.2,u(e,t,i,n),e.stroke(),e.globalAlpha=1}function p(e,t,i,n){u(e,t,i-n,n),e.fill(),e.stroke()}function g(e,t,i,n){const{point:o}=t;e.globalAlpha=.2,e.beginPath(),e.arc(o.x,o.y,i+n/2,0,2*Math.PI,!0),e.closePath(),e.lineWidth=n,e.stroke(),e.globalAlpha=1}function x(e,t,i,n){const{point:o}=t;e.beginPath(),e.arc(o.x,o.y,i-n/2,0,2*Math.PI,!0),e.closePath(),e.lineWidth=n,e.fill(),e.stroke()}class T extends h.BitmapCoordinatesPaneRenderer{constructor(e){super(),this._data=e??null}setData(e){this._data=e}hitTest(e){if(null===this._data||this._data.disableInteractions)return null;const{radius:t,points:i}=this._data,n=t+(0,c.interactionTolerance)().anchor;for(const t of i){if(t.point.subtract(e).length()<=n)return new d.HitTestResult(t.hitTarget??d.HitTarget.ChangePoint,{areaName:d.AreaName.AnchorPoint,pointIndex:t.pointIndex,cursorType:t.cursorType??l.PaneCursorType.Default,activeItem:t.activeItem,snappingPrice:t.snappingPrice,snappingIndex:t.snappingIndex,nonDiscreteIndex:t.nonDiscreteIndex,possibleMovingDirections:t.possibleMovingDirections,clickHandler:this._data.clickHandler,tapHandler:this._data.clickHandler,ownerSourceId:this._data.ownerSourceId})}return null}doesIntersectWithBox(e){return null!==this._data&&this._data.points.some((t=>(0,o.pointInBox)(t.point,e)))}_drawImpl(e){if(null===this._data||!this._data.visible)return;const t=[],i=[],n=[],o=[];for(let e=0;e<this._data.points.length;++e){const s=this._data.points[e],r=this._data.backgroundColors[e];s.square?(t.push(s),i.push(r)):(n.push(s),o.push(r))}t.length&&this._drawPoints(e,t,i,p,_),n.length&&this._drawPoints(e,n,o,x,g)}_drawPoints(e,t,i,o,r){const{context:d,horizontalPixelRatio:l,verticalPixelRatio:c}=e,h=(0,s.ensureNotNull)(this._data),u=h.radius;let _=Math.max(1,Math.floor((h.strokeWidth||2)*l));h.selected&&(_+=Math.max(1,Math.floor(l/2)));const p=Math.max(1,Math.floor(l));let g=Math.round(u*l*2);g%2!=p%2&&(g+=1);const x=p%2/2;d.strokeStyle=h.color;for(let e=0;e<t.length;++e){const s=t[e];if(!((0,a.isInteger)(s.pointIndex)&&h.linePointBeingEdited===s.pointIndex)){d.fillStyle=i[e];if(o(d,{...s,point:new n.Point(Math.round(s.point.x*l)+x,Math.round(s.point.y*c)+x)},g/2,_),!h.disableInteractions){if(null!==h.hoveredPointIndex&&s.pointIndex===h.hoveredPointIndex){const e=Math.max(1,Math.floor(h.selectedStrokeWidth*l));let t=Math.round(u*l*2);t%2!=p%2&&(t+=1);r(d,{...s,point:new n.Point(Math.round(s.point.x*l)+x,Math.round(s.point.y*c)+x)},t/2,e)}}}}}}function P(e,t=e.pointIndex,i,n,o,s,r,a,d,l){return{point:e,pointIndex:t,cursorType:i,square:n,hitTarget:o,snappingPrice:s,snappingIndex:r,nonDiscreteIndex:a,activeItem:d,possibleMovingDirections:l}}function m(e){return P(e.point,e.pointIndex,e.cursorType,e.square,e.hitTarget,e.snappingPrice,e.snappingIndex,e.nonDiscreteIndex,e.activeItem,e.possibleMovingDirections)}function S(e){return P(e)}}}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
[data-theme=light]{--_0-aZuU:var(--color-tv-blue-500);--_1-aZuU:var(--color-black)}[data-theme=dark]{--_0-aZuU:var(--color-tv-blue-500);--_1-aZuU:var(--color-cold-gray-300)}.container-QcG0kDOU{align-items:center;cursor:default;display:flex;flex-direction:column;justify-content:center;text-align:center}@media (max-height:440px) and (orientation:landscape){.container-QcG0kDOU{justify-content:flex-start}}.image-QcG0kDOU{margin-bottom:12px}@media (max-height:440px) and (orientation:landscape){.image-QcG0kDOU{display:none}}.title-QcG0kDOU{color:var(--_1-aZuU);font-size:20px;font-weight:700;margin:0 0 16px}.description-QcG0kDOU{color:var(--color-text-primary);font-size:16px;line-height:24px;margin:0}.button-QcG0kDOU{cursor:default;margin-top:24px}
|
||||
@@ -1 +0,0 @@
|
||||
[data-theme=light]{--_0-aZuU:var(--color-tv-blue-500);--_1-aZuU:var(--color-black)}[data-theme=dark]{--_0-aZuU:var(--color-tv-blue-500);--_1-aZuU:var(--color-cold-gray-300)}.container-QcG0kDOU{align-items:center;cursor:default;display:flex;flex-direction:column;justify-content:center;text-align:center}@media (max-height:440px) and (orientation:landscape){.container-QcG0kDOU{justify-content:flex-start}}.image-QcG0kDOU{margin-bottom:12px}@media (max-height:440px) and (orientation:landscape){.image-QcG0kDOU{display:none}}.title-QcG0kDOU{color:var(--_1-aZuU);font-size:20px;font-weight:700;margin:0 0 16px}.description-QcG0kDOU{color:var(--color-text-primary);font-size:16px;line-height:24px;margin:0}.button-QcG0kDOU{cursor:default;margin-top:24px}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user