erwin 2 mesi fa
parent
commit
29e498cf0d

+ 378 - 0
market-regime-identifier-30/cyb50_30min_classifier.py

@@ -0,0 +1,378 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+创业板50市场状态分类器 - 30分钟级别
+基于本地5分钟数据文件,聚合成30分钟K线
+"""
+
+import numpy as np
+import pandas as pd
+from sklearn.ensemble import RandomForestClassifier
+from sklearn.metrics import classification_report, confusion_matrix
+import warnings
+warnings.filterwarnings('ignore')
+
+
+def load_5min_data(filepath='SZ#399673.txt'):
+    """加载5分钟数据文件"""
+    print(f"加载5分钟数据: {filepath}")
+
+    # 读取数据,跳过前两行(标题行),过滤注释行
+    df = pd.read_csv(filepath, sep='\t', skiprows=2, encoding='gbk', header=None,
+                     comment='#', on_bad_lines='skip')
+
+    # 指定列名
+    df.columns = ['date', 'time', 'open', 'high', 'low', 'close', 'volume', 'amount']
+
+    # 过滤掉包含非日期数据的行
+    df = df[df['date'].astype(str).str.match(r'\d{4}/\d{2}/\d{2}')].copy()
+
+    # 创建datetime索引
+    # 处理time列: 如果是数字,格式化为4位时间字符串
+    def format_time(t):
+        if pd.isna(t):
+            return '0000'
+        t = int(t)
+        return f"{t:04d}"
+
+    df['time_str'] = df['time'].apply(format_time)
+    df['datetime'] = pd.to_datetime(df['date'] + ' ' + df['time_str'],
+                                     format='%Y/%m/%d %H%M')
+    df = df.set_index('datetime').sort_index()
+    df = df.drop('time_str', axis=1)
+
+    # 转换为数值类型
+    for col in ['open', 'high', 'low', 'close', 'volume', 'amount']:
+        df[col] = pd.to_numeric(df[col], errors='coerce')
+
+    print(f"[OK] 加载成功: {len(df)}条5分钟数据")
+    print(f"  日期范围: {df.index[0]} ~ {df.index[-1]}")
+    print(f"  价格范围: {df['close'].min():.2f} ~ {df['close'].max():.2f}")
+
+    return df
+
+
+def resample_to_30min(df_5min):
+    """将5分钟数据聚合成30分钟数据"""
+    print("\n聚合成30分钟数据...")
+
+    # 30分钟重采样规则
+    df_30min = df_5min.resample('30min').agg({
+        'open': 'first',
+        'high': 'max',
+        'low': 'min',
+        'close': 'last',
+        'volume': 'sum',
+        'amount': 'sum'
+    }).dropna()
+
+    # 计算收益率
+    df_30min['return'] = df_30min['close'].pct_change()
+
+    print(f"[OK] 聚合完成: {len(df_30min)}条30分钟数据")
+
+    return df_30min
+
+
+def calculate_features_30min(df):
+    """计算30分钟级别的技术指标特征"""
+    features = pd.DataFrame(index=df.index)
+
+    # 价格特征
+    features['close'] = df['close']
+
+    # 1. 收益率特征(30分钟周期)
+    features['ret_1'] = df['return']  # 1个30分钟周期
+    features['ret_4'] = df['close'].pct_change(4)  # 2小时
+    features['ret_8'] = df['close'].pct_change(8)  # 4小时(半日)
+    features['ret_16'] = df['close'].pct_change(16)  # 8小时(1个交易日)
+
+    # 2. 波动率特征(30分钟周期)
+    features['volatility_4'] = df['return'].rolling(4).std() * np.sqrt(48)  # 2小时波动率年化
+    features['volatility_16'] = df['return'].rolling(16).std() * np.sqrt(48)  # 日波动率年化
+    features['volatility_ratio'] = features['volatility_4'] / (features['volatility_16'] + 1e-10)
+
+    # 3. 动量特征
+    features['momentum_8'] = df['close'] / df['close'].shift(8) - 1  # 4小时动量
+    features['momentum_16'] = df['close'] / df['close'].shift(16) - 1  # 日动量
+
+    # 4. 均线特征(30分钟周期)
+    features['ma4'] = df['close'].rolling(4).mean()  # 2小时均线
+    features['ma16'] = df['close'].rolling(16).mean()  # 日均线
+    features['ma48'] = df['close'].rolling(48).mean()  # 3日均线
+    features['ma4_above_ma16'] = (features['ma4'] > features['ma16']).astype(int)
+
+    # 5. RSI(14个30分钟周期 = 7小时)
+    delta = df['close'].diff()
+    gain = (delta.where(delta > 0, 0)).rolling(14).mean()
+    loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
+    rs = gain / (loss + 1e-10)
+    features['rsi_14'] = 100 - (100 / (1 + rs))
+    features['rsi_overbought'] = (features['rsi_14'] > 70).astype(int)
+    features['rsi_oversold'] = (features['rsi_14'] < 30).astype(int)
+
+    # 6. MACD
+    ema12 = df['close'].ewm(span=12).mean()
+    ema26 = df['close'].ewm(span=26).mean()
+    features['macd'] = ema12 - ema26
+    features['macd_signal'] = features['macd'].ewm(span=9).mean()
+    features['macd_hist'] = features['macd'] - features['macd_signal']
+
+    # 7. 布林带
+    features['bb_middle'] = df['close'].rolling(20).mean()
+    bb_std = df['close'].rolling(20).std()
+    features['bb_upper'] = features['bb_middle'] + 2 * bb_std
+    features['bb_lower'] = features['bb_middle'] - 2 * bb_std
+    features['bb_position'] = (df['close'] - features['bb_lower']) / (features['bb_upper'] - features['bb_lower'] + 1e-10)
+
+    # 8. 成交量特征
+    features['volume_ratio'] = df['volume'] / df['volume'].rolling(16).mean()
+    features['volume_spike'] = (features['volume_ratio'] > 2).astype(int)
+
+    # 9. 趋势强度(ADX近似)
+    high_low = df['high'] - df['low']
+    features['atr_14'] = high_low.rolling(14).mean()
+    features['atr_ratio'] = features['atr_14'] / df['close']
+
+    # 10. 日内时间特征
+    features['hour'] = df.index.hour
+    features['is_morning'] = ((features['hour'] >= 9) & (features['hour'] < 11)).astype(int)
+    features['is_afternoon'] = ((features['hour'] >= 13) & (features['hour'] < 15)).astype(int)
+
+    # 11. 价格变化加速度
+    features['price_accel'] = df['close'].diff().diff()
+    features['price_accel_normalized'] = features['price_accel'] / (df['close'] * 0.01)
+
+    # 12. 连续涨跌周期数
+    features['consecutive_up'] = (df['return'] > 0).astype(int).groupby((df['return'] <= 0).astype(int).cumsum()).cumsum()
+    features['consecutive_down'] = (df['return'] < 0).astype(int).groupby((df['return'] >= 0).astype(int).cumsum()).cumsum()
+
+    # 填充缺失值
+    features = features.ffill().fillna(0)
+
+    return features
+
+
+def define_market_regime_30min(df, lookback=8):
+    """
+    基于规则定义30分钟市场状态标签
+
+    参数:
+        lookback: 回看周期数(默认8 = 4小时)
+    """
+    labels = []
+
+    # 预计算RSI
+    delta = df['close'].diff()
+    gain = (delta.where(delta > 0, 0)).rolling(14).mean()
+    loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
+    rs = gain / (loss + 1e-10)
+    rsi = 100 - (100 / (1 + rs))
+
+    for i in range(len(df)):
+        if i < lookback:
+            labels.append(0)
+            continue
+
+        # 获取回看期间数据
+        period_close = df['close'].iloc[i-lookback:i]
+        period_high = df['high'].iloc[i-lookback:i]
+        period_low = df['low'].iloc[i-lookback:i]
+        period_rsi = rsi.iloc[i-lookback:i]
+
+        start_price = period_close.iloc[0]
+        end_price = period_close.iloc[-1]
+        period_return = (end_price / start_price - 1) * 100
+
+        daily_returns = period_close.pct_change().dropna()
+        volatility = daily_returns.std() * np.sqrt(48) * 100
+
+        max_price = period_high.max()
+        min_price = period_low.min()
+
+        mid = lookback // 2
+        first_half_return = (period_close.iloc[mid] / start_price - 1) * 100
+        second_half_return = (end_price / period_close.iloc[mid] - 1) * 100
+
+        # RSI特征
+        rsi_start = period_rsi.iloc[0]
+        rsi_end = period_rsi.iloc[-1]
+        rsi_change = rsi_end - rsi_start
+
+        # 定义标签
+        label = 0  # 默认震荡
+
+        # ========== 反转判断 ==========
+        condition_1 = (rsi_start > 68 and rsi_change < -15) or (rsi_start < 32 and rsi_change > 15)
+        condition_2 = (first_half_return * second_half_return < 0 and
+                      abs(first_half_return) > 1.5 and abs(second_half_return) > 1.0)
+        condition_3 = (period_rsi.max() > 72 or period_rsi.min() < 28)
+        condition_4 = 12 < volatility < 40
+
+        reversal_score = sum([condition_1, condition_2, condition_3, condition_4])
+        if reversal_score >= 2:
+            label = 2
+
+        # ========== 趋势判断 ==========
+        elif abs(period_return) >= 2.5 and volatility < 35:
+            if reversal_score < 2:
+                label = 1
+
+        labels.append(label)
+
+    return np.array(labels)
+
+
+def train_and_predict(df_30min, features, labels):
+    """训练模型并预测"""
+    print("\n训练30分钟级别分类器...")
+
+    # 对齐数据
+    valid_idx = ~np.isnan(labels)
+    X = features[valid_idx]
+    y = labels[valid_idx]
+    df_aligned = df_30min.iloc[valid_idx].copy()
+
+    # 分割训练集和测试集(按时间顺序,80%训练,20%测试)
+    split_idx = int(len(X) * 0.8)
+    X_train, X_test = X.iloc[:split_idx], X.iloc[split_idx:]
+    y_train, y_test = y[:split_idx], y[split_idx:]
+
+    print(f"训练集: {len(X_train)}条")
+    print(f"测试集: {len(X_test)}条")
+
+    # 训练模型
+    clf = RandomForestClassifier(
+        n_estimators=200,
+        max_depth=15,
+        min_samples_split=10,
+        min_samples_leaf=5,
+        random_state=42,
+        class_weight={0: 1.0, 1: 1.2, 2: 2.0}
+    )
+
+    clf.fit(X_train, y_train)
+
+    # 评估
+    train_score = clf.score(X_train, y_train)
+    test_score = clf.score(X_test, y_test)
+
+    print(f"\n训练准确率: {train_score:.2%}")
+    print(f"测试准确率: {test_score:.2%}")
+
+    # 详细报告
+    y_pred = clf.predict(X_test)
+    print("\n分类报告:")
+    print(classification_report(y_test, y_pred, target_names=['震荡', '趋势', '反转']))
+
+    # 预测所有数据
+    all_pred = clf.predict(X)
+    all_proba = clf.predict_proba(X)
+
+    # 添加预测结果到DataFrame
+    df_aligned['state'] = all_pred
+    df_aligned['prob_ranging'] = all_proba[:, 0]
+    df_aligned['prob_trend'] = all_proba[:, 1]
+    df_aligned['prob_reversal'] = all_proba[:, 2]
+
+    # 特征重要性
+    feature_importance = pd.DataFrame({
+        'feature': X.columns,
+        'importance': clf.feature_importances_
+    }).sort_values('importance', ascending=False)
+
+    print("\n特征重要性 TOP 10:")
+    print(feature_importance.head(10).to_string(index=False))
+
+    return clf, df_aligned, feature_importance
+
+
+def analyze_regime_distribution(df_result):
+    """分析市场状态分布"""
+    print("\n" + "="*70)
+    print("30分钟市场状态分析")
+    print("="*70)
+
+    state_names = ['震荡', '趋势', '反转']
+
+    # 整体分布
+    print("\n【整体分布】")
+    for i, name in enumerate(state_names):
+        count = (df_result['state'] == i).sum()
+        pct = count / len(df_result) * 100
+        print(f"  {name}: {count}个周期 ({pct:.1f}%)")
+
+    # 按日期统计
+    print("\n【最近5个交易日状态分布】")
+    df_result['date'] = df_result.index.date
+    recent_dates = df_result['date'].unique()[-5:]
+
+    for date in recent_dates:
+        day_data = df_result[df_result['date'] == date]
+        print(f"\n  {date}:")
+        for i, name in enumerate(state_names):
+            count = (day_data['state'] == i).sum()
+            print(f"    {name}: {count}个30分钟周期")
+
+    # 当前状态
+    latest = df_result.iloc[-1]
+    current_state = state_names[int(latest['state'])]
+
+    print("\n【当前状态】")
+    print(f"  时间: {df_result.index[-1]}")
+    print(f"  收盘价: {latest['close']:.2f}")
+    print(f"  市场状态: {current_state}")
+    print(f"  置信度: {latest[['prob_ranging', 'prob_trend', 'prob_reversal']].max():.2%}")
+    print(f"  概率分布: 震荡{latest['prob_ranging']:.1%} / 趋势{latest['prob_trend']:.1%} / 反转{latest['prob_reversal']:.1%}")
+
+
+def main():
+    """主程序"""
+    print("="*70)
+    print("创业板50市场状态分类器 - 30分钟级别")
+    print("="*70)
+
+    # 1. 加载5分钟数据
+    df_5min = load_5min_data('SZ#399673.txt')
+
+    # 2. 聚合成30分钟数据
+    df_30min = resample_to_30min(df_5min)
+
+    # 3. 计算特征
+    print("\n计算30分钟技术指标...")
+    features = calculate_features_30min(df_30min)
+    print(f"特征数量: {features.shape[1]}")
+
+    # 4. 定义标签
+    print("\n定义市场状态标签...")
+    labels = define_market_regime_30min(df_30min, lookback=8)
+
+    # 统计标签分布
+    unique, counts = np.unique(labels, return_counts=True)
+    print("\n标签分布:")
+    state_names = ['震荡', '趋势', '反转']
+    for u, c in zip(unique, counts):
+        print(f"  {state_names[u]}: {c}个周期 ({c/len(labels)*100:.1f}%)")
+
+    # 5. 训练并预测
+    clf, df_result, importance = train_and_predict(df_30min, features, labels)
+
+    # 6. 分析结果
+    analyze_regime_distribution(df_result)
+
+    # 7. 保存结果
+    print("\n保存结果...")
+    df_result.to_csv('cyb50_30min_regime_result.csv')
+    print("[OK] 结果已保存: cyb50_30min_regime_result.csv")
+
+    # 保存模型
+    import pickle
+    with open('rf_classifier_30min.pkl', 'wb') as f:
+        pickle.dump(clf, f)
+    print("[OK] 模型已保存: rf_classifier_30min.pkl")
+
+    print("\n" + "="*70)
+
+
+if __name__ == "__main__":
+    main()

BIN
market-regime-identifier-30/cyb50_30min_regime_chart.png


File diff suppressed because it is too large
+ 4521 - 0
market-regime-identifier-30/cyb50_30min_regime_result.csv


+ 37 - 74
market-regime-identifier-30/daily_email_sender.py

@@ -2,8 +2,7 @@
 # -*- coding: utf-8 -*-
 """
 CYB50市场状态识别 - 每日邮件发送脚本
-数据范围: 2024年至今
-发送时间: 每天15:10
+通过本地SMTP服务器(直接投递模式)发送邮件
 """
 
 import sys
@@ -16,21 +15,27 @@ from sklearn.ensemble import RandomForestClassifier
 import smtplib
 from email.mime.text import MIMEText
 from email.mime.multipart import MIMEMultipart
-from email.mime.base import MIMEBase
 from email.header import Header
-from email import encoders
 from datetime import datetime
+import os
 import warnings
 warnings.filterwarnings('ignore')
 
+# SMTP配置
+SMTP_SERVER = os.getenv('SMTP_SERVER', 'localhost')
+SMTP_PORT = int(os.getenv('SMTP_PORT', '25'))
+SENDER = os.getenv('SMTP_SENDER', 'kalman@openclaw.local')
+RECEIVER = '380880504@qq.com'
+
 print("="*60)
 print(f"CYB50每日市场状态报告 - {datetime.now().strftime('%Y-%m-%d %H:%M')}")
+print(f"SMTP: {SMTP_SERVER}:{SMTP_PORT}")
 print("="*60)
 
 # 获取数据
 df = fetch_cyb50_data('2024-01-01', '2026-12-31')
 if df is None:
-    print("数据获取失败")
+    print("数据获取失败")
     exit(1)
 
 # 计算特征和标签
@@ -59,10 +64,6 @@ df_aligned['prob_ranging'] = probs[:, 0]
 df_aligned['prob_trend'] = probs[:, 1]
 df_aligned['prob_reversal'] = probs[:, 2]
 
-# 获取最近365天
-last_365 = df_aligned.tail(365).copy()
-last_365['change'] = last_365['close'].pct_change() * 100
-
 # 获取最新数据
 today = df_aligned.iloc[-1]
 yesterday = df_aligned.iloc[-2] if len(df_aligned) > 1 else today
@@ -72,36 +73,28 @@ colors = ['#2196F3', '#4CAF50', '#FF5722']
 state_name = state_names[int(today['state'])]
 state_color = colors[int(today['state'])]
 
-# 生成365天详细数据表格
+# 计算涨跌
+daily_change = today['close'] - yesterday['close']
+daily_change_pct = daily_change / yesterday['close'] * 100
+
+# 获取最近30天
+last_30 = df_aligned.tail(30).copy()
+
+# 生成HTML表格
 html_rows = ""
-for idx, row in last_365.iterrows():
+for idx, row in last_30.iterrows():
     s = int(row['state'])
-    change = row['change'] if not pd.isna(row['change']) else 0
-    change_str = f"{change:+.2f}%" if change != 0 else "-"
-    change_color = "green" if change > 0 else "red" if change < 0 else "gray"
-    
-    # 高亮最新一天
-    highlight = 'style="background: #fff3cd; font-weight: bold;"' if idx == df_aligned.index[-1] else ''
-    
     html_rows += f"""
-        <tr {highlight}>
-            <td>{idx.strftime('%y-%m-%d')}</td>
+        <tr>
+            <td>{idx.strftime('%m-%d')}</td>
             <td>{row['close']:.2f}</td>
             <td style="color: {colors[s]}; font-weight: bold;">{state_names[s]}</td>
             <td>{row['prob_ranging']:.1%}</td>
             <td>{row['prob_trend']:.1%}</td>
             <td>{row['prob_reversal']:.1%}</td>
-            <td style="color: {change_color};">{change_str}</td>
         </tr>
     """
 
-# 计算涨跌
-daily_change = today['close'] - yesterday['close']
-daily_change_pct = daily_change / yesterday['close'] * 100
-
-# 计算区间涨跌
-range_change_pct = (today['close'] / last_365['close'].iloc[0] - 1) * 100
-
 # 邮件内容
 html = f"""
 <html>
@@ -110,46 +103,25 @@ html = f"""
     <style>
         body {{ font-family: Arial, sans-serif; margin: 20px; font-size: 12px; }}
         h1 {{ color: #333; border-bottom: 3px solid #2196F3; padding-bottom: 10px; font-size: 18px; }}
-        h2 {{ color: #555; margin-top: 20px; border-left: 4px solid #4CAF50; padding-left: 10px; font-size: 14px; }}
-        .summary {{ background: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0; }}
-        .summary p {{ margin: 5px 0; }}
         .today {{ background: #e3f2fd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #2196F3; }}
         table {{ width: 100%; border-collapse: collapse; margin: 20px 0; font-size: 11px; }}
         th {{ background: #2196F3; color: white; padding: 8px; text-align: center; }}
         td {{ padding: 6px 8px; border-bottom: 1px solid #ddd; text-align: center; }}
         tr:nth-child(even) {{ background: #f8f9fa; }}
-        tr:hover {{ background: #e3f2fd; }}
-        .legend {{ font-size: 11px; margin-top: 10px; }}
-        .legend span {{ margin-right: 15px; }}
     </style>
 </head>
 <body>
-    <h1>📊 CYB50每日市场状态报告</h1>
-    
+    <h1>CYB50每日市场状态报告</h1>
+
     <div class="today">
-        <h2>📈 今日状态 ({df_aligned.index[-1].strftime('%Y-%m-%d')})</h2>
+        <h2>今日状态 ({df_aligned.index[-1].strftime('%Y-%m-%d')})</h2>
         <p><strong>收盘价:</strong> {today['close']:.2f}</p>
         <p><strong>日涨跌:</strong> <span style="color: {'green' if daily_change >= 0 else 'red'};">{daily_change:+.2f} ({daily_change_pct:+.2f}%)</span></p>
         <p><strong>市场状态:</strong> <span style="color: {state_color}; font-size: 16px; font-weight: bold;">{state_name}</span></p>
         <p><strong>状态概率:</strong> 震荡 {today['prob_ranging']:.1%} / 趋势 {today['prob_trend']:.1%} / 反转 {today['prob_reversal']:.1%}</p>
     </div>
-    
-    <div class="summary">
-        <h2>📊 最近365天统计 (2024-至今)</h2>
-        <p><strong>365天前价格:</strong> {last_365['close'].iloc[0]:.2f}</p>
-        <p><strong>区间涨跌:</strong> <span style="color: {'green' if range_change_pct >= 0 else 'red'};">{range_change_pct:+.2f}%</span></p>
-        <p><strong>最高价:</strong> {last_365['close'].max():.2f} ({last_365['close'].idxmax().strftime('%m-%d')}) / <strong>最低价:</strong> {last_365['close'].min():.2f} ({last_365['close'].idxmin().strftime('%m-%d')})</p>
-        <br>
-        <p><strong>状态分布:</strong> 🟦 震荡 {(last_365['state']==0).sum()}天 / 🟩 趋势 {(last_365['state']==1).sum()}天 / 🟧 反转 {(last_365['state']==2).sum()}天</p>
-    </div>
-    
-    <h2>📋 最近365天详细数据</h2>
-    <p class="legend">
-        <span>🟦 震荡</span>
-        <span>🟩 趋势</span>
-        <span>🟧 反转</span>
-        <span>(黄色背景 = 最新)</span>
-    </p>
+
+    <h2>最近30天数据</h2>
     <table>
         <thead>
             <tr>
@@ -159,14 +131,13 @@ html = f"""
                 <th>震荡概率</th>
                 <th>趋势概率</th>
                 <th>反转概率</th>
-                <th>日涨跌</th>
             </tr>
         </thead>
         <tbody>
             {html_rows}
         </tbody>
     </table>
-    
+
     <hr>
     <p style="color: #666; font-size: 11px;">
         生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}<br>
@@ -178,26 +149,18 @@ html = f"""
 """
 
 # 发送邮件
-EMAIL_CONFIG = {
-    "smtp_server": "localhost",
-    "smtp_port": 25,
-    "sender_email": "kalman@openclaw.local",
-    "receiver_email": "380880504@qq.com"
-}
-
 msg = MIMEMultipart('related')
-msg['Subject'] = Header(f"📊 CYB50-Regime每日市场状态报告 [{df_aligned.index[-1].strftime('%m-%d')}] 当前{state_name}", 'utf-8')
-msg['From'] = "regime <regime@openclaw.local>"
-msg['To'] = EMAIL_CONFIG['receiver_email']
+msg['Subject'] = Header(f"CYB50-Regime每日报告 [{df_aligned.index[-1].strftime('%m-%d')}] 当前{state_name}", 'utf-8')
+msg['From'] = f"regime <{SENDER}>"
+msg['To'] = RECEIVER
 msg.attach(MIMEText(html, 'html', 'utf-8'))
 
 try:
-    with smtplib.SMTP(EMAIL_CONFIG['smtp_server'], EMAIL_CONFIG['smtp_port']) as server:
-        server.sendmail(
-            EMAIL_CONFIG['sender_email'],
-            EMAIL_CONFIG['receiver_email'],
-            msg.as_string()
-        )
-    print(f"✅ 邮件发送成功! [{df_aligned.index[-1].strftime('%Y-%m-%d')}] 当前状态: {state_name}")
+    with smtplib.SMTP(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
+        server.sendmail(SENDER, RECEIVER, msg.as_string())
+    print(f"[OK] 邮件发送成功! [{df_aligned.index[-1].strftime('%Y-%m-%d')}] 当前状态: {state_name}")
 except Exception as e:
-    print(f"❌ 邮件发送失败: {e}")
+    print(f"[ERROR] 邮件发送失败: {e}")
+    print(f"请确保SMTP服务器已启动: python smtp_relay_server.py --direct")
+
+print("="*60)

+ 129 - 0
market-regime-identifier-30/generate_30min_regime_chart.py

@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+生成30分钟级别市场状态识别图表
+"""
+
+import pandas as pd
+import matplotlib.pyplot as plt
+import matplotlib.dates as mdates
+from datetime import datetime
+import warnings
+warnings.filterwarnings('ignore')
+
+print("="*70)
+print("生成30分钟市场状态识别图表")
+print("="*70)
+
+# 读取30分钟结果数据
+df = pd.read_csv('cyb50_30min_regime_result.csv', index_col='datetime', parse_dates=True)
+
+print(f"\n数据范围: {df.index[0]} ~ {df.index[-1]}")
+print(f"数据条数: {len(df)}个30分钟周期")
+
+# 设置中文字体
+plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
+plt.rcParams['axes.unicode_minus'] = False
+
+# 状态名称和颜色
+state_names = ['震荡', '趋势', '反转']
+colors = ['#2196F3', '#4CAF50', '#FF5722']  # 蓝、绿、橙
+
+# 创建图表
+fig, axes = plt.subplots(3, 1, figsize=(20, 14))
+
+# 图1: 价格走势 + 状态标记
+ax1 = axes[0]
+ax1.plot(df.index, df['close'], 'k-', alpha=0.3, linewidth=0.5, label='收盘价')
+
+for i, (name, color) in enumerate(zip(state_names, colors)):
+    mask = df['state'] == i
+    if mask.any():
+        ax1.scatter(df.index[mask], df['close'][mask],
+                   c=color, label=name, alpha=0.6, s=20)
+
+ax1.set_ylabel('价格', fontsize=12)
+ax1.set_title('CYB50 30分钟市场状态识别 (2024-2026)', fontsize=14, fontweight='bold')
+ax1.legend(loc='upper left', fontsize=10)
+ax1.grid(True, alpha=0.3)
+
+# 格式化x轴日期
+ax1.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
+ax1.xaxis.set_major_locator(mdates.MonthLocator(interval=2))
+
+# 图2: 状态概率时间序列
+ax2 = axes[1]
+ax2.fill_between(df.index, 0, df['prob_ranging'],
+                 alpha=0.5, label='震荡', color=colors[0])
+ax2.fill_between(df.index, df['prob_ranging'],
+                 df['prob_ranging'] + df['prob_trend'],
+                 alpha=0.5, label='趋势', color=colors[1])
+ax2.fill_between(df.index,
+                 df['prob_ranging'] + df['prob_trend'], 1,
+                 alpha=0.5, label='反转', color=colors[2])
+
+ax2.set_ylabel('概率', fontsize=12)
+ax2.set_title('30分钟状态概率时间序列', fontsize=12)
+ax2.legend(loc='upper left', fontsize=10)
+ax2.grid(True, alpha=0.3)
+ax2.set_ylim(0, 1)
+ax2.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m'))
+ax2.xaxis.set_major_locator(mdates.MonthLocator(interval=2))
+
+# 图3: 状态分布统计
+ax3 = axes[2]
+state_counts = df['state'].value_counts().sort_index()
+bars = ax3.bar(range(3), state_counts.values, color=colors, alpha=0.7)
+ax3.set_xticks(range(3))
+ax3.set_xticklabels(state_names)
+ax3.set_ylabel('周期数', fontsize=12)
+ax3.set_title('30分钟状态分布统计', fontsize=12)
+
+# 添加数值标签
+for i, (bar, count) in enumerate(zip(bars, state_counts.values)):
+    pct = count / len(df) * 100
+    ax3.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 20,
+             f'{count}\n({pct:.1f}%)',
+             ha='center', va='bottom', fontsize=11, fontweight='bold')
+
+plt.tight_layout()
+plt.savefig('cyb50_30min_regime_chart.png', dpi=150, bbox_inches='tight')
+print("\n[OK] 图表已保存: cyb50_30min_regime_chart.png")
+
+# 生成详细报告
+print("\n" + "="*70)
+print("30分钟级别详细识别结果")
+print("="*70)
+
+# 按月份统计
+print("\n【月度统计 - 最近6个月】")
+print(f"{'月份':<12} {'总周期':<8} {'震荡':<8} {'趋势':<8} {'反转':<8} {'主要状态':<10}")
+print("-"*70)
+
+recent_months = df[df.index >= '2024-09-01'].copy()
+for year_month in sorted(recent_months.index.to_period('M').unique()):
+    mask = recent_months.index.to_period('M') == year_month
+    month_data = recent_months[mask]
+    total = len(month_data)
+    ranging = (month_data['state'] == 0).sum()
+    trend = (month_data['state'] == 1).sum()
+    reversal = (month_data['state'] == 2).sum()
+
+    main_state = state_names[month_data['state'].mode()[0]]
+
+    print(f"{str(year_month):<12} {total:<8} {ranging:<8} {trend:<8} {reversal:<8} {main_state:<10}")
+
+# 最近一周详细数据
+print("\n【最近一周详细数据】")
+print(f"{'日期时间':<20} {'收盘价':<10} {'状态':<8} {'震荡%':<8} {'趋势%':<8} {'反转%':<8}")
+print("-"*70)
+
+last_week = df.tail(40)  # 约5个交易日
+for idx, row in last_week.iterrows():
+    state = state_names[int(row['state'])]
+    print(f"{str(idx):<20} {row['close']:<10.2f} {state:<8} "
+          f"{row['prob_ranging']:<8.1%} {row['prob_trend']:<8.1%} {row['prob_reversal']:<8.1%}")
+
+print("\n" + "="*70)
+print("[OK] 报告生成完成!")
+print("="*70)

+ 107 - 0
market-regime-identifier-30/received_emails.log

@@ -0,0 +1,107 @@
+
+======================================================================
+Email #1
+Time: 2026-03-09 17:48:06
+From: kalman@openclaw.local
+To: 380880504@qq.com
+----------------------------------------------------------------------
+Content-Type: multipart/related; boundary="===============6939752981513331998=="

+MIME-Version: 1.0

+Subject: =?utf-8?b?W1RFU1RdIENZQjUw5biC5Zy654q25oCB5rWL6K+V6YKu5Lu2IFswMy0wOSAxNzo0OF0=?=

+From: Regime Tester <kalman@openclaw.local>

+To: 380880504@qq.com

+

+--===============6939752981513331998==

+Content-Type: text/html; charset="utf-8"

+MIME-Version: 1.0

+Content-Transfer-Encoding: base64

+

+CjxodG1sPgo8aGVhZD4KICAgIDxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KICAgIDxzdHlsZT4KICAg

+ICAgICBib2R5IHsgZm9udC1mYW1pbHk6IEFyaWFsLCBzYW5zLXNlcmlmOyBtYXJnaW46IDIwcHg7

+IH0KICAgICAgICAuaGVhZGVyIHsgYmFja2dyb3VuZDogIzIxOTZGMzsgY29sb3I6IHdoaXRlOyBw

+YWRkaW5nOiAxNXB4OyBib3JkZXItcmFkaXVzOiA1cHg7IH0KICAgICAgICAuY29udGVudCB7IG1h

+cmdpbjogMjBweCAwOyBwYWRkaW5nOiAxNXB4OyBiYWNrZ3JvdW5kOiAjZjVmNWY1OyBib3JkZXIt

+cmFkaXVzOiA1cHg7IH0KICAgIDwvc3R5bGU+CjwvaGVhZD4KPGJvZHk+CiAgICA8ZGl2IGNsYXNz

+PSJoZWFkZXIiPgogICAgICAgIDxoMj5DWUI1MCBTTVRQ5rWL6K+V6YKu5Lu2PC9oMj4KICAgIDwv

+ZGl2PgogICAgPGRpdiBjbGFzcz0iY29udGVudCI+CiAgICAgICAgPHA+PHN0cm9uZz7lj5HpgIHm

+l7bpl7Q6PC9zdHJvbmc+IDIwMjYtMDMtMDkgMTc6NDg6MDY8L3A+CiAgICAgICAgPHA+PHN0cm9u

+Zz5TTVRQ5pyN5Yqh5ZmoOjwvc3Ryb25nPiBsb2NhbGhvc3Q6MTAyNTwvcD4KICAgICAgICA8cD48

+c3Ryb25nPuaUtuS7tuS6ujo8L3N0cm9uZz4gMzgwODgwNTA0QHFxLmNvbTwvcD4KICAgICAgICA8

+cD7ov5nmmK/kuIDlsIHmtYvor5Xpgq7ku7bvvIznlKjkuo7pqozor4HmnKzlnLBTTVRQ5pyN5Yqh

+5Zmo5piv5ZCm5q2j5bi45bel5L2c44CCPC9wPgogICAgPC9kaXY+CiAgICA8aHI+CiAgICA8cCBz

+dHlsZT0iY29sb3I6ICM2NjY7IGZvbnQtc2l6ZTogMTFweDsiPgogICAgICAgIOeUn+aIkOaXtumX

+tDogMjAyNi0wMy0wOSAxNzo0ODxicj4KICAgICAgICDliJvkuJrmnb81MOW4guWcuueKtuaAgeiv

+huWIq+ezu+e7nwogICAgPC9wPgo8L2JvZHk+CjwvaHRtbD4K

+

+--===============6939752981513331998==--

+
+======================================================================
+
+======================================================================
+Email #1
+Time: 2026-03-09 17:51:58
+From: kalman@openclaw.local
+To: 380880504@qq.com
+----------------------------------------------------------------------
+Content-Type: multipart/related; boundary="===============8489886545837289424=="

+MIME-Version: 1.0

+Subject: =?utf-8?b?W1RFU1RdIENZQjUw5biC5Zy654q25oCB5rWL6K+V6YKu5Lu2IFswMy0wOSAxNzo1MV0=?=

+From: Regime Tester <kalman@openclaw.local>

+To: 380880504@qq.com

+

+--===============8489886545837289424==

+Content-Type: text/html; charset="utf-8"

+MIME-Version: 1.0

+Content-Transfer-Encoding: base64

+

+CjxodG1sPgo8aGVhZD4KICAgIDxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KICAgIDxzdHlsZT4KICAg

+ICAgICBib2R5IHsgZm9udC1mYW1pbHk6IEFyaWFsLCBzYW5zLXNlcmlmOyBtYXJnaW46IDIwcHg7

+IH0KICAgICAgICAuaGVhZGVyIHsgYmFja2dyb3VuZDogIzIxOTZGMzsgY29sb3I6IHdoaXRlOyBw

+YWRkaW5nOiAxNXB4OyBib3JkZXItcmFkaXVzOiA1cHg7IH0KICAgICAgICAuY29udGVudCB7IG1h

+cmdpbjogMjBweCAwOyBwYWRkaW5nOiAxNXB4OyBiYWNrZ3JvdW5kOiAjZjVmNWY1OyBib3JkZXIt

+cmFkaXVzOiA1cHg7IH0KICAgIDwvc3R5bGU+CjwvaGVhZD4KPGJvZHk+CiAgICA8ZGl2IGNsYXNz

+PSJoZWFkZXIiPgogICAgICAgIDxoMj5DWUI1MCBTTVRQ5rWL6K+V6YKu5Lu2PC9oMj4KICAgIDwv

+ZGl2PgogICAgPGRpdiBjbGFzcz0iY29udGVudCI+CiAgICAgICAgPHA+PHN0cm9uZz7lj5HpgIHm

+l7bpl7Q6PC9zdHJvbmc+IDIwMjYtMDMtMDkgMTc6NTE6NTg8L3A+CiAgICAgICAgPHA+PHN0cm9u

+Zz5TTVRQ5pyN5Yqh5ZmoOjwvc3Ryb25nPiBsb2NhbGhvc3Q6MTAyNTwvcD4KICAgICAgICA8cD48

+c3Ryb25nPuaUtuS7tuS6ujo8L3N0cm9uZz4gMzgwODgwNTA0QHFxLmNvbTwvcD4KICAgICAgICA8

+cD7ov5nmmK/kuIDlsIHmtYvor5Xpgq7ku7bvvIznlKjkuo7pqozor4HmnKzlnLBTTVRQ5pyN5Yqh

+5Zmo5piv5ZCm5q2j5bi45bel5L2c44CCPC9wPgogICAgPC9kaXY+CiAgICA8aHI+CiAgICA8cCBz

+dHlsZT0iY29sb3I6ICM2NjY7IGZvbnQtc2l6ZTogMTFweDsiPgogICAgICAgIOeUn+aIkOaXtumX

+tDogMjAyNi0wMy0wOSAxNzo1MTxicj4KICAgICAgICDliJvkuJrmnb81MOW4guWcuueKtuaAgeiv

+huWIq+ezu+e7nwogICAgPC9wPgo8L2JvZHk+CjwvaHRtbD4K

+

+--===============8489886545837289424==--

+
+======================================================================
+
+[Direct Attempt] 2026-03-09 18:00:40.052540
+Content-Type: multipart/related; boundary="===============1503376331835390579=="

+MIME-Version: 1.0

+Subject: =?utf-8?b?W1RFU1RdIENZQjUw5biC5Zy654q25oCB5rWL6K+V6YKu5Lu2IFswMy0wOSAxODowMF0=?=

+From: Regime Tester <kalman@openclaw.local>

+To: 380880504@qq.com

+

+--===============1503376331835390579==

+Content-Type: text/html; charset="utf-8"

+MIME-Version: 1.0

+Content-Transfer-Encoding: base64

+

+CjxodG1sPgo8aGVhZD4KICAgIDxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KICAgIDxzdHlsZT4KICAg

+ICAgICBib2R5IHsgZm9udC1mYW1pbHk6IEFyaWFsLCBzYW5zLXNlcmlmOyBtYXJnaW46IDIwcHg7

+IH0KICAgICAgICAuaGVhZGVyIHsgYmFja2dyb3VuZDogIzIxOTZGMzsgY29sb3I6IHdoaXRlOyBw

+YWRkaW5nOiAxNXB4OyBib3JkZXItcmFkaXVzOiA1cHg7IH0KICAgICAgICAuY29udGVudCB7IG1h

+cmdpbjogMjBweCAwOyBwYWRkaW5nOiAxNXB4OyBiYWNrZ3JvdW5kOiAjZjVmNWY1OyBib3JkZXIt

+cmFkaXVzOiA1cHg7IH0KICAgIDwvc3R5bGU+CjwvaGVhZD4KPGJvZHk+CiAgICA8ZGl2IGNsYXNz

+PSJoZWFkZXIiPgogICAgICAgIDxoMj5DWUI1MCBTTVRQ5rWL6K+V6YKu5Lu2PC9oMj4KICAgIDwv

+ZGl2PgogICAgPGRpdiBjbGFzcz0iY29udGVudCI+CiAgICAgICAgPHA+PHN0cm9uZz7lj5HpgIHm

+l7bpl7Q6PC9zdHJvbmc+IDIwMjYtMDMtMDkgMTg6MDA6MzU8L3A+CiAgICAgICAgPHA+PHN0cm9u

+Zz5TTVRQ5pyN5Yqh5ZmoOjwvc3Ryb25nPiBsb2NhbGhvc3Q6MTAyNTwvcD4KICAgICAgICA8cD48

+c3Ryb25nPuaUtuS7tuS6ujo8L3N0cm9uZz4gMzgwODgwNTA0QHFxLmNvbTwvcD4KICAgICAgICA8

+cD7ov5nmmK/kuIDlsIHmtYvor5Xpgq7ku7bvvIznlKjkuo7pqozor4HmnKzlnLBTTVRQ5pyN5Yqh

+5Zmo5piv5ZCm5q2j5bi45bel5L2c44CCPC9wPgogICAgPC9kaXY+CiAgICA8aHI+CiAgICA8cCBz

+dHlsZT0iY29sb3I6ICM2NjY7IGZvbnQtc2l6ZTogMTFweDsiPgogICAgICAgIOeUn+aIkOaXtumX

+tDogMjAyNi0wMy0wOSAxODowMDxicj4KICAgICAgICDliJvkuJrmnb81MOW4guWcuueKtuaAgeiv

+huWIq+ezu+e7nwogICAgPC9wPgo8L2JvZHk+CjwvaHRtbD4K

+

+--===============1503376331835390579==--


BIN
market-regime-identifier-30/rf_classifier_30min.pkl


+ 230 - 0
market-regime-identifier-30/smtp_relay_server.py

@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+本地SMTP中继服务器 - 将邮件转发到外部邮箱
+支持两种方式:
+1. 直接投递(会被拒收): python smtp_relay_server.py --direct
+2. 通过QQ邮箱中继(推荐): python smtp_relay_server.py --relay
+"""
+
+import asyncio
+import smtplib
+import sys
+from datetime import datetime
+from aiosmtpd.controller import Controller
+from aiosmtpd.handlers import Proxy
+from email import message_from_bytes
+
+# ========== 配置你的QQ邮箱中继 ==========
+RELAY_HOST = "smtp.qq.com"
+RELAY_PORT = 587
+RELAY_USER = "380880504@qq.com"     # QQ邮箱
+RELAY_PASS = "your_auth_code"        # QQ邮箱授权码
+# =======================================
+
+
+class SmartRelayHandler:
+    """
+    智能中继处理器
+    - 将收到的邮件通过真实SMTP服务器转发
+    """
+
+    def __init__(self, relay_host=None, relay_port=None, relay_user=None, relay_pass=None):
+        self.relay_host = relay_host
+        self.relay_port = relay_port
+        self.relay_user = relay_user
+        self.relay_pass = relay_pass
+        self.use_relay = all([relay_host, relay_port, relay_user, relay_pass])
+        self.email_count = 0
+
+    async def handle_DATA(self, server, session, envelope):
+        """处理收到的邮件并转发"""
+        self.email_count += 1
+
+        mail_from = envelope.mail_from
+        rcpt_tos = envelope.rcpt_tos
+        data = envelope.content
+
+        print(f"\n{'='*70}")
+        print(f"[RECEIVE] 收到邮件 #{self.email_count}")
+        print(f"{'='*70}")
+        print(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+        print(f"发件人: {mail_from}")
+        print(f"收件人: {', '.join(rcpt_tos)}")
+        print(f"大小: {len(data)} bytes")
+
+        # 解析邮件主题
+        try:
+            msg = message_from_bytes(data)
+            subject = msg.get('Subject', 'No Subject')
+            print(f"主题: {subject}")
+        except:
+            pass
+
+        # 转发邮件
+        if self.use_relay:
+            success = self._relay_email(mail_from, rcpt_tos, data)
+            if success:
+                print(f"[RELAY] 邮件已通过 {self.relay_host} 转发成功")
+            else:
+                print(f"[FAIL] 转发失败,邮件已保存到本地")
+                self._save_local(mail_from, rcpt_tos, data)
+        else:
+            print(f"[MODE] 本地模式 - 未配置中继,仅保存到文件")
+            self._save_local(mail_from, rcpt_tos, data)
+
+        return '250 OK'
+
+    def _relay_email(self, mail_from, rcpt_tos, data):
+        """通过真实SMTP服务器转发邮件"""
+        try:
+            print(f"[RELAY] 正在连接 {self.relay_host}:{self.relay_port}...")
+            with smtplib.SMTP(self.relay_host, self.relay_port, timeout=30) as server:
+                server.starttls()
+                print(f"[RELAY] 正在登录 {self.relay_user}...")
+                server.login(self.relay_user, self.relay_pass)
+                print(f"[RELAY] 正在发送给 {', '.join(rcpt_tos)}...")
+                server.sendmail(self.relay_user, rcpt_tos, data)
+            return True
+        except Exception as e:
+            print(f"[RELAY ERROR] {e}")
+            return False
+
+    def _save_local(self, mail_from, rcpt_tos, data):
+        """保存到本地文件"""
+        try:
+            with open("received_emails.log", 'a', encoding='utf-8') as f:
+                f.write(f"\n{'='*70}\n")
+                f.write(f"Email #{self.email_count} - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
+                f.write(f"From: {mail_from}\n")
+                f.write(f"To: {', '.join(rcpt_tos)}\n")
+                f.write(f"{'-'*70}\n")
+                f.write(data.decode('utf-8', errors='replace'))
+                f.write(f"\n{'='*70}\n")
+            print(f"[SAVE] 已保存到 received_emails.log")
+        except Exception as e:
+            print(f"[SAVE ERROR] {e}")
+
+
+class DirectDeliveryHandler:
+    """
+    直接投递处理器
+    - 尝试直接连接到收件人域名的MX记录投递
+    - 大概率会被拒收(因为没有SPF/DKIM)
+    """
+
+    def __init__(self):
+        self.email_count = 0
+
+    async def handle_DATA(self, server, session, envelope):
+        self.email_count += 1
+        mail_from = envelope.mail_from
+        rcpt_tos = envelope.rcpt_tos
+        data = envelope.content
+
+        print(f"\n{'='*70}")
+        print(f"[DIRECT] 收到邮件 #{self.email_count}")
+        print(f"{'='*70}")
+        print(f"警告: 直接投递模式,大概率会被拒收!")
+        print(f"发件人: {mail_from}")
+        print(f"收件人: {', '.join(rcpt_tos)}")
+
+        # 尝试直接投递
+        for rcpt in rcpt_tos:
+            domain = rcpt.split('@')[1]
+            print(f"[DIRECT] 尝试投递到 {domain}...")
+            try:
+                # 这里简化处理,实际需要查询MX记录
+                self._try_deliver(domain, mail_from, rcpt, data)
+            except Exception as e:
+                print(f"[DIRECT FAIL] {e}")
+
+        # 保存备份
+        with open("received_emails.log", 'a', encoding='utf-8') as f:
+            f.write(f"\n[Direct Attempt] {datetime.now()}\n")
+            f.write(data.decode('utf-8', errors='replace'))
+
+        return '250 OK'
+
+    def _try_deliver(self, domain, mail_from, rcpt_to, data):
+        """尝试直接连接到目标域的邮件服务器"""
+        import dns.resolver
+        try:
+            # 查询MX记录
+            answers = dns.resolver.resolve(domain, 'MX')
+            mx_host = str(answers[0].exchange)
+            print(f"[DIRECT] MX记录: {mx_host}")
+
+            # 连接并投递
+            with smtplib.SMTP(mx_host, 25, timeout=10) as server:
+                server.sendmail(mail_from, [rcpt_to], data)
+            print(f"[DIRECT] 投递成功!")
+        except Exception as e:
+            print(f"[DIRECT] 投递失败: {e}")
+
+
+def main():
+    import argparse
+
+    parser = argparse.ArgumentParser(description='本地SMTP中继服务器')
+    parser.add_argument('--port', type=int, default=25,
+                        help='本地监听端口 (默认: 25)')
+    parser.add_argument('--host', type=str, default='localhost',
+                        help='监听地址 (默认: localhost)')
+    parser.add_argument('--relay', action='store_true',
+                        help='使用中继模式 (通过QQ邮箱转发)')
+    parser.add_argument('--direct', action='store_true',
+                        help='使用直接投递模式 (会被拒收)')
+
+    args = parser.parse_args()
+
+    # 选择处理器
+    if args.direct:
+        handler = DirectDeliveryHandler()
+        mode = "直接投递 (大概率被拒收)"
+    elif args.relay:
+        if RELAY_PASS == "your_auth_code":
+            print("[ERROR] 请先配置QQ邮箱授权码!")
+            print(f"编辑 {__file__},修改 RELAY_PASS = '你的授权码'")
+            sys.exit(1)
+        handler = SmartRelayHandler(RELAY_HOST, RELAY_PORT, RELAY_USER, RELAY_PASS)
+        mode = f"中继模式 (via {RELAY_HOST})"
+    else:
+        handler = SmartRelayHandler()  # 本地模式
+        mode = "本地保存 (未配置中继)"
+
+    # 启动服务器
+    controller = Controller(handler, hostname=args.host, port=args.port)
+
+    print("="*70)
+    print(f"[SMTP Relay Server] 启动成功")
+    print("="*70)
+    print(f"模式: {mode}")
+    print(f"监听: {args.host}:{args.port}")
+    print(f"测试: python send_simple_test_email.py")
+    print("-"*70)
+
+    if args.relay:
+        print(f"中继账号: {RELAY_USER}")
+        print("邮件将转发到收件人邮箱")
+    else:
+        print("邮件仅保存到本地: received_emails.log")
+        print("要启用转发,请运行: python smtp_relay_server.py --relay")
+
+    print(f"\n按 Ctrl+C 停止服务器")
+    print("="*70)
+
+    controller.start()
+
+    try:
+        while True:
+            import time
+            time.sleep(1)
+    except KeyboardInterrupt:
+        print("\n\n正在停止服务器...")
+        controller.stop()
+        print("[OK] 服务器已停止")
+
+
+if __name__ == '__main__':
+    main()

+ 20 - 0
market-regime-identifier-30/start_relay_server.bat

@@ -0,0 +1,20 @@
+@echo off
+chcp 65001 >nul
+echo ========================================
+echo  启动SMTP直接投递服务器 (端口25)
+echo ========================================
+echo.
+echo 说明:
+echo   1. 此服务器尝试直接投递邮件到目标邮箱
+echo   2. 投递到QQ邮箱可能会被拒收(无SPF/DKIM)
+echo.
+echo 使用方式:
+echo   1. 保持此窗口运行
+echo   2. 在另一个窗口运行: python daily_email_sender.py
+echo.
+echo ========================================
+echo.
+
+py smtp_relay_server.py --direct
+
+pause