9 Commits
Author SHA1 Message Date
jackyu66git cae126fafc fix: pipeline MACD 参数统一为标准 12/26/9(与 web/交易所一致) 2026-09-12 02:15:24 +08:00
jackyu66gitandGitHub 8413aa1d17 Merge pull request #19 from jackyu66git/dev
Dev
2026-04-08 01:29:56 +08:00
jackyu66gitandGitHub dbe189348e Merge pull request #18 from jackyu66git/dev
web端进行优化,减少内存开销,data provider提供websocket服务
2026-04-05 18:24:40 +08:00
jackyu66gitandGitHub c40f34fac9 Merge pull request #17 from jackyu66git/dev
添加了很多识别功能,特别是中枢相关的
2026-04-05 17:56:14 +08:00
jackyu66gitandGitHub 62dfb5e28f Merge pull request #16 from jackyu66git/dev
Dev
2026-03-24 16:35:18 +08:00
jackyu66gitandGitHub 9fdf0e11bc Merge pull request #15 from jackyu66git/dev
Dev
2026-03-21 01:41:05 +08:00
jackyu66gitandGitHub 6f2d5c4fa6 Merge pull request #14 from jackyu66git/dev
Dev
2026-03-15 17:09:49 +08:00
jackyu66gitandGitHub 4c37463472 Merge pull request #13 from jackyu66git/dev
增加缠论说明,优化线段中枢
2026-03-12 17:43:20 +08:00
jackyu66gitandGitHub 2273d9190e Merge pull request #12 from jackyu66git/dev
Dev
2026-03-11 11:38:12 +08:00
2031 changed files with 377 additions and 55331 deletions
Vendored
BIN
View File
Binary file not shown.
-3
View File
@@ -37,6 +37,3 @@ feature_meta
.DS_Store
.DS_Store
.DS_Store
.DS_Store
data_provider/._config.json
.gstack/
-110
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
-12
View File
@@ -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
View File
@@ -1 +0,0 @@
data/
-9
View File
@@ -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"
-364
View File
@@ -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()
-23
View File
@@ -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
}
-113
View File
@@ -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()
-224
View File
@@ -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
-4
View File
@@ -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
-55
View File
@@ -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)
-295
View File
@@ -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
-271
View File
@@ -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}
-5
View File
@@ -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
-69
View File
@@ -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."""
...
-189
View File
@@ -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()
-66
View File
@@ -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
-157
View File
@@ -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
-17
View File
@@ -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()
-370
View File
@@ -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
-213
View File
@@ -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
View File
-8
View File
@@ -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
-11
View File
@@ -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
-6
View File
@@ -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
-28
View File
@@ -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."""
...
-218
View File
@@ -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)
-98
View File
@@ -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
-137
View File
@@ -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")
-248
View File
@@ -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"
-143
View File
@@ -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")
View File
-134
View File
@@ -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
-173
View File
@@ -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
-130
View File
@@ -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
-109
View File
@@ -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
-121
View File
@@ -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
-81
View File
@@ -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
)
-5
View File
@@ -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
-174
View File
@@ -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)
-192
View File
@@ -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),
}
-144
View File
@@ -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)
-120
View File
@@ -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)
View File
-158
View File
@@ -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)
-171
View File
@@ -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();
-144
View File
@@ -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>
-292
View File
@@ -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)
-145
View File
@@ -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)
-6
View File
@@ -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
View File
@@ -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)
-3
View File
@@ -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
+5 -328
View File
@@ -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):
"""
笔中枢的三类买卖点识别
-3
View File
@@ -1,3 +0,0 @@
# bsp_monitor 复用 Hermes Agent 的 Telegram bot
# notify.py 从 ~/.hermes/.env 直接读取 TELEGRAM_BOT_TOKEN
# 此处无需重复配置
-1
View File
@@ -1 +0,0 @@
# bsp_monitor - 缠论买卖点监控 (BTC/USDT 1m)
-174
View File
@@ -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)
-62
View File
@@ -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
-220
View File
@@ -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("&", "&amp;")
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("<", "&lt;").replace(">", "&gt;")
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("收到中断信号,退出")
-61
View File
@@ -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
-11
View File
@@ -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
-89
View File
@@ -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
}
}
-68
View File
@@ -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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+2 -2
View File
@@ -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"]
-82
View File
@@ -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`
-568
View File
@@ -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 &nbsp;|&nbsp; <span>binance</span> &nbsp;|&nbsp; 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 &mdash; 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>
+6 -19
View File
@@ -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"
}
+2 -2
View File
@@ -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"
-417
View File
@@ -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 &nbsp;·&nbsp; Built with FastAPI + ccxt + pandas &nbsp;·&nbsp;
更新于 <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>
+123 -1048
View File
File diff suppressed because it is too large Load Diff
-321
View File
@@ -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 分钟定环境。
-133
View File
@@ -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])
-213
View File
@@ -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])
-201
View File
@@ -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])
-133
View File
@@ -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])
-272
View File
@@ -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)
-300
View File
@@ -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-103current_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'])
BIN
View File
Binary file not shown.
+34 -227
View File
@@ -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