stock-flow/.agent/skills/sakata/scripts/sakata_analyzer.py

468 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Sakata Analyzer v2.0 - 酒田戰法分析器 (改良版)
Main entry point for candlestick pattern analysis.
改良重點:
1. 整合結構分析 (頸線、量價背離)
2. 改進報告格式 (顯示型態階段、關鍵價位)
3. 趨勢背景信息
Usage:
python sakata_analyzer.py --ticker AAPL
python sakata_analyzer.py --ticker TSLA --days 180
"""
import argparse
import os
import time
from datetime import datetime, timedelta
import pandas as pd
import yfinance as yf
from pattern_detector import detect_all_patterns, summarize_detected_patterns
from chart_plotter import create_sakata_chart
from signal_generator import SignalGenerator
# Rate limiting settings for Yahoo Finance
REQUEST_DELAY = 1.0 # seconds between requests
MAX_RETRIES = 3
RETRY_DELAY = 5.0 # seconds
def fetch_stock_data(ticker: str, days: int = 120, retries: int = 0) -> pd.DataFrame:
"""
Fetch OHLCV data from Yahoo Finance with rate limiting.
"""
print(f"📊 正在取得 {ticker}{days} 日數據...")
try:
time.sleep(REQUEST_DELAY)
end_date = datetime.now()
start_date = end_date - timedelta(days=days + 30)
stock = yf.Ticker(ticker)
df = stock.history(start=start_date, end=end_date)
if df.empty:
raise ValueError(f"No data found for {ticker}")
df = df.tail(days)
print(f"✅ 成功取得 {len(df)} 筆數據 ({df.index[0].strftime('%Y-%m-%d')} ~ {df.index[-1].strftime('%Y-%m-%d')})")
return df
except Exception as e:
if retries < MAX_RETRIES:
print(f"⚠️ 請求失敗,{RETRY_DELAY} 秒後重試... ({retries + 1}/{MAX_RETRIES})")
time.sleep(RETRY_DELAY * (retries + 1))
return fetch_stock_data(ticker, days, retries + 1)
else:
raise Exception(f"Failed to fetch data for {ticker} after {MAX_RETRIES} retries: {e}")
def generate_structure_section(analysis: dict, pattern_type: str) -> str:
"""
生成結構分析區塊 (頸線、量價背離)。
"""
if not analysis.get('detected'):
return ""
name = '三山' if pattern_type == 'sanzan' else '三川'
stage = analysis.get('stage', 0)
section = f"""
## 🏔️ {name}型態結構分析
| 項目 | 數值 |
|------|------|
| **型態階段** | {stage}/5 {'⚠️ 預警中' if stage < 4 else '✅ 確立'} |
"""
# 頸線信息
neckline = analysis.get('neckline', {})
if neckline and neckline.get('neckline'):
status = '✅ 已跌破' if neckline.get('confirmed') else '⏳ 未跌破'
if pattern_type == 'sansen':
status = '✅ 已突破' if neckline.get('confirmed') else '⏳ 未突破'
section += f"""| **頸線價位** | ${neckline['neckline']:.2f} |
| **頸線狀態** | {status} |
| **距離頸線** | {neckline.get('distance_pct', 0):.1f}% |
"""
# 量價背離
vol_div = analysis.get('volume_divergence', {})
if vol_div and vol_div.get('divergence'):
section += f"""| **量價背離** | ⚠️ {vol_div['description']} |
"""
# 關鍵價位
if pattern_type == 'sanzan' and analysis.get('peaks'):
avg_peak = analysis.get('avg_peak', 0)
section += f"""| **平均山頂** | ${avg_peak:.2f} |
"""
elif pattern_type == 'sansen' and analysis.get('troughs'):
avg_trough = analysis.get('avg_trough', 0)
section += f"""| **平均谷底** | ${avg_trough:.2f} |
"""
# 型態階段說明
stage_desc = {
1: '僅形成一個頂/底',
2: '形成兩個頂/底',
3: '第三頂/底形成中',
4: '型態成形,等待確認',
5: '型態確立,已突破/跌破頸線'
}
section += f"""
### 階段說明
{stage_desc.get(stage, '未知')}
"""
return section
def generate_trend_section(trend_ctx: dict, current_price: float) -> str:
"""
生成趨勢背景區塊。
"""
position_map = {'high': '高檔', 'mid': '中間', 'low': '低檔'}
trend_map = {'uptrend': '上升趨勢 📈', 'downtrend': '下降趨勢 📉', 'sideways': '盤整 ↔️'}
ma20 = trend_ctx.get('ma20', [0])[-1] if len(trend_ctx.get('ma20', [])) > 0 else 0
ma60 = trend_ctx.get('ma60', [0])[-1] if len(trend_ctx.get('ma60', [])) > 0 else 0
section = f"""
## 📊 趨勢背景
| 指標 | 數值 | 說明 |
|------|------|------|
| **當前位置** | {position_map.get(trend_ctx.get('position', 'mid'), '中間')} | 近60日區間位置 ({trend_ctx.get('position_pct', 0)*100:.0f}%) |
| **趨勢方向** | {trend_map.get(trend_ctx.get('trend', 'sideways'), '盤整')} | 基於 MA20 斜率 |
| **MA20** | ${ma20:.2f} | {'股價在上方 ✅' if current_price > ma20 else '股價在下方 ❌'} |
| **MA60** | ${ma60:.2f} | {'股價在上方 ✅' if current_price > ma60 else '股價在下方 ❌'} |
| **近期高點** | ${trend_ctx.get('recent_high', 0):.2f} | |
| **近期低點** | ${trend_ctx.get('recent_low', 0):.2f} | |
"""
return section
def generate_markdown_report(
ticker: str,
df: pd.DataFrame,
detected_patterns: list,
signals: list,
chart_path: str,
analysis_result: dict,
output_dir: str = './output'
) -> str:
"""
Generate an improved Markdown analysis report with structure analysis.
"""
signal_gen = SignalGenerator()
summary = signal_gen.summarize_signals(signals)
recommendation = signal_gen.get_latest_recommendation(
signals,
current_price=df.iloc[-1]['Close'],
current_date=df.index[-1]
)
current_price = df.iloc[-1]['Close']
trend_ctx = analysis_result.get('trend_context', {})
sanzan_analysis = analysis_result.get('sanzan_analysis', {})
sansen_analysis = analysis_result.get('sansen_analysis', {})
# 判斷是否有結構型態
has_structure = sanzan_analysis.get('detected') or sansen_analysis.get('detected')
# Build report
report = f"""# {ticker} 酒田戰法分析報告 v2.0
**生成時間**: {datetime.now().strftime('%Y-%m-%d %H:%M')}
**數據範圍**: {df.index[0].strftime('%Y-%m-%d')} ~ {df.index[-1].strftime('%Y-%m-%d')} ({len(df)} 日)
**當前價格**: ${current_price:.2f}
---
## 📊 核心結論
| 指標 | 數值 |
|------|------|
| 總信號數 | {summary['total']} |
| 多頭信號 | {summary['bullish']} 🟢 |
| 空頭信號 | {summary['bearish']} 🔴 |
| 整體偏向 | **{summary['bias']}** |
| 信心度 | {'' * int(summary['avg_strength'])} ({summary['avg_strength']}/5) |
"""
# 添加趨勢背景
report += generate_trend_section(trend_ctx, current_price)
# 添加結構分析 (如果有)
if sanzan_analysis.get('detected'):
report += generate_structure_section(sanzan_analysis, 'sanzan')
if sansen_analysis.get('detected'):
report += generate_structure_section(sansen_analysis, 'sansen')
# 交易建議
report += """---
## 🎯 最新交易建議
"""
if recommendation:
# v2.2: 根據狀態決定顯示方式
status = recommendation.get('status', 'ACTIVE')
status_reason = recommendation.get('status_reason', '')
days_diff = recommendation.get('days_since_signal', 0)
# 決定 emoji 和建議文字
if recommendation['recommendation'] == 'HOLD':
rec_emoji = '⏸️ 觀望'
elif recommendation['recommendation'] == 'BUY':
rec_emoji = '🟢 買入'
else:
rec_emoji = '🔴 賣出'
# 狀態標籤
status_badge = ''
if status == 'EXPIRED':
status_badge = '⚠️ 已過期'
elif status == 'STOPPED_OUT':
status_badge = '🛑 已停損'
elif status == 'SPECIAL':
status_badge = '⚡ 特殊情況'
elif status == 'ACTIVE':
status_badge = '✅ 有效'
report += f"""| 項目 | 數值 |
|------|------|
| **建議** | {rec_emoji} |
| **信號狀態** | {status_badge} |
| **觸發型態** | {recommendation['pattern']} |
| **信號日期** | {recommendation['date'].strftime('%Y-%m-%d') if hasattr(recommendation['date'], 'strftime') else recommendation['date']} ({days_diff} 天前) |
| **建議進場** | ${recommendation['entry']:.2f} |
| **停損價位** | ${recommendation['stop_loss']:.2f} |
| **目標價位** | ${recommendation['target']:.2f} |
| **信號強度** | {recommendation['strength']} |
"""
# 狀態原因說明
if status_reason:
report += f"""> **狀態說明**: {status_reason}
"""
# 連續窗口警示
window_status = recommendation.get('window_status', {})
if window_status.get('warning'):
report += f"""> **酒田特殊警示**: {window_status['warning']}
"""
# 只有 ACTIVE 狀態才顯示盈虧比
if status == 'ACTIVE':
risk = abs(recommendation['entry'] - recommendation['stop_loss'])
reward = abs(recommendation['target'] - recommendation['entry'])
rr_ratio = reward / risk if risk > 0 else 0
report += f"""**盈虧比 (R/R)**: 1 : {rr_ratio:.1f}
"""
else:
report += "> ⚠️ 目前無明確交易信號,建議觀望。\n\n"
# 偵測到的型態 (去重後顯示)
report += """---
## 📈 偵測到的型態
| 日期 | 型態 | 英文名稱 | 方向 | 強度 |
|------|------|---------|------|------|
"""
# 去重: 同一天同一型態只顯示一次
seen = set()
unique_patterns = []
for pattern in detected_patterns[:20]:
key = (pattern['date'].strftime('%Y-%m-%d') if hasattr(pattern['date'], 'strftime') else str(pattern['date'])[:10], pattern['name'])
if key not in seen:
seen.add(key)
unique_patterns.append(pattern)
for pattern in unique_patterns[:12]:
date_str = pattern['date'].strftime('%m/%d') if hasattr(pattern['date'], 'strftime') else str(pattern['date'])[:10]
direction = '🟢 多頭' if pattern['direction'] == 'bullish' else '🔴 空頭'
strength = '' * min(5, max(1, pattern['strength'] // 20))
report += f"| {date_str} | {pattern['name']} | {pattern['english']} | {direction} | {strength} |\n"
report += f"""
---
## 📉 K 線圖表
![{ticker} 酒田戰法分析]({os.path.basename(chart_path)})
---
## ⚠️ 風險提示
1. 本分析僅供參考,不構成投資建議
2. K 線型態需配合成交量、趨勢等其他指標確認
3. **三山/三川型態需跌破/突破頸線才算確立**
4. 請嚴格執行停損紀律
5. 過去表現不代表未來結果
---
*由酒田戰法 Agent Skill v2.0 自動生成*
"""
# Save report
os.makedirs(output_dir, exist_ok=True)
report_path = os.path.join(output_dir, f'{ticker}_sakata.md')
with open(report_path, 'w', encoding='utf-8') as f:
f.write(report)
return report_path
def analyze(ticker: str, days: int = 120, output_dir: str = './output'):
"""
Run full Sakata analysis on a stock.
"""
print(f"\n{'='*60}")
print(f"🏯 酒田戰法分析器 v2.0 - {ticker}")
print(f"{'='*60}\n")
# 1. Fetch data
df = fetch_stock_data(ticker, days)
# 2. Detect patterns (v2.0 - returns rich analysis)
print("\n🔍 正在偵測 K 線型態 (含結構分析)...")
analysis_result = detect_all_patterns(df)
pattern_results = analysis_result['patterns']
trend_ctx = analysis_result['trend_context']
sanzan_analysis = analysis_result['sanzan_analysis']
sansen_analysis = analysis_result['sansen_analysis']
detected_patterns = summarize_detected_patterns(df, pattern_results)
print(f"✅ 偵測到 {len(detected_patterns)} 個型態")
# 顯示結構分析結果
if sanzan_analysis.get('detected'):
print(f" 📉 三山型態: 階段 {sanzan_analysis.get('stage')}/5")
if sansen_analysis.get('detected'):
print(f" 📈 三川型態: 階段 {sansen_analysis.get('stage')}/5")
# 3. Generate signals
print("\n📈 正在產生交易信號...")
signal_gen = SignalGenerator()
signals = signal_gen.generate_signals(df, detected_patterns, trend_ctx)
print(f"✅ 產生 {len(signals)} 個交易信號")
# 4. Create chart
print("\n🎨 正在繪製圖表...")
chart_path = create_sakata_chart(df, detected_patterns, signals, ticker, output_dir)
print(f"✅ 圖表已儲存: {chart_path}")
# 5. Generate report
print("\n📝 正在產生報告...")
report_path = generate_markdown_report(
ticker, df, detected_patterns, signals, chart_path, analysis_result, output_dir
)
print(f"✅ 報告已儲存: {report_path}")
# 6. Summary
summary = signal_gen.summarize_signals(signals)
recommendation = signal_gen.get_latest_recommendation(
signals,
current_price=df.iloc[-1]['Close'],
current_date=df.index[-1]
)
print(f"\n{'='*60}")
print("📊 分析完成!")
print(f"{'='*60}")
print(f" 趨勢位置: {trend_ctx.get('position', 'mid')} | 趨勢方向: {trend_ctx.get('trend', 'sideways')}")
print(f" 多頭信號: {summary['bullish']} | 空頭信號: {summary['bearish']}")
print(f" 整體偏向: {summary['bias']}")
if recommendation:
print(f"\n 🎯 最新建議: {recommendation['recommendation']}")
print(f" 型態: {recommendation['pattern']}")
print(f" 進場: ${recommendation['entry']:.2f}")
print(f" 停損: ${recommendation['stop_loss']:.2f}")
print(f" 目標: ${recommendation['target']:.2f}")
print(f"\n 📁 輸出檔案:")
print(f" {chart_path}")
print(f" {report_path}")
print(f"{'='*60}\n")
return {
'chart': chart_path,
'report': report_path,
'patterns': detected_patterns,
'signals': signals,
'summary': summary,
'analysis': analysis_result
}
def main():
parser = argparse.ArgumentParser(
description='酒田戰法分析器 v2.0 - Sakata Candlestick Pattern Analyzer',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
範例:
python sakata_analyzer.py --ticker AAPL
python sakata_analyzer.py --ticker TSLA --days 180
python sakata_analyzer.py --ticker NVDA --output ./charts
"""
)
parser.add_argument(
'--ticker', '-t',
type=str,
required=True,
help='股票代碼 (例: AAPL, TSLA, 2330.TW)'
)
parser.add_argument(
'--days', '-d',
type=int,
default=120,
help='分析天數 (預設: 120)'
)
parser.add_argument(
'--output', '-o',
type=str,
default='./output',
help='輸出目錄 (預設: ./output)'
)
args = parser.parse_args()
analyze(
ticker=args.ticker.upper(),
days=args.days,
output_dir=args.output
)
if __name__ == '__main__':
main()