crypto 历史数据获取

Flask应用, 通过前端获取数据,后端保存文件。
template/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>数据获取与保存工具</title>
    <style>
        body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
        .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); max-width: 800px; margin: 0 auto; }
        h1, h2 { color: #555; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 15px; }
        label { display: block; margin-top: 10px; margin-bottom: 5px; font-weight: bold; }
        input[type="text"], input[type="number"], input[type="date"], select {
            width: calc(100% - 22px);
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
            font-size: 1rem;
        }
        button {
            background-color: #007bff;
            color: white;
            padding: 10px 15px;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            margin-top: 15px;
            font-size: 1rem;
            transition: background-color 0.2s ease;
        }
        button:hover { background-color: #0056b3; }
        button:disabled { background-color: #cccccc; cursor: not-allowed; }

        .file-list { list-style-type: none; padding: 0; margin-top: 10px; }
        .file-list li { background-color: #e9e9e9; margin-bottom: 5px; padding: 8px; border-radius: 3px; word-break: break-all;}
        .error { color: #a94442; background-color: #f2dede; border: 1px solid #ebccd1; padding: 10px; border-radius: 4px; margin-bottom:15px; }
        .success { color: #3c763d; background-color: #dff0d8; border: 1px solid #d6e9c6; padding: 10px; border-radius: 4px; margin-bottom:15px; }
        .info { color: #31708f; background-color: #d9edf7; border: 1px solid #bce8f1; padding: 10px; border-radius: 4px; margin-bottom:15px;}
        .form-group { margin-bottom: 15px; }
        .checkbox-group label { display: inline-block; margin-left: 5px; font-weight: normal;}
        .error-details {
            white-space: pre-wrap;
            font-family: monospace;
            margin-top: 10px;
            padding: 10px;
            background-color: #f8d7da;
            border: 1px solid #f5c6cb;
            border-radius: 4px;
            color: #721c24;
            max-height: 300px;
            overflow-y: auto;
            word-break: break-all;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>加密货币K线数据获取与保存</h1>
        <p class="info">
            请选择币种、交易所、时间周期和起始日期,然后点击下方按钮获取数据并自动保存。<br>
            <strong>注意:</strong> 获取历史恐惧贪婪指数功能仅支持日线 (1d) 和周线 (1w) 时间周期。
        </p>

        <div class="form-group">
            <label for="coin_name">币种名称 (例如: BTC/USDT, ETH/BTC):</label>
            <select id="coin_name">
                {% for coin_symbol in coins_available %}
                    <option value="{{ coin_symbol }}/USDT">{{ coin_symbol }}/USDT</option>
                {% endfor %}
                <option value="OTHER">其他 (手动输入)</option>
            </select>
            <input type="text" id="coin_name_other" placeholder="若选择其他, 请在此输入币种全名 (如: BTC/USDT)" style="display:none; margin-top:5px;">
        </div>

        <div class="form-group">
            <label for="exchange_name">交易所名称 (例如: binance, okx):</label>
            <input type="text" id="exchange_name" value="binance">
        </div>

        <div class="form-group">
            <label for="start_date">数据获取起始日期:</label>
            <input type="date" id="start_date" value="{{ default_start_date }}">
        </div>

        <div class="form-group">
            <label for="time_period">时间周期:</label>
            <select id="time_period">
                <option value="1d" selected>1天 (1d)</option>
                <option value="1w">1周 (1w)</option>
                <option value="4h">4小时 (4h)</option>
            </select>
        </div>

        <div class="form-group checkbox-group">
            <input type="checkbox" id="fetch_historical_fng" checked>
            <label for="fetch_historical_fng">自动获取恐惧贪婪指数 (仅适用于 1d, 1w 时间周期)</label>
        </div>

        <button id="fetchButton" onclick="fetchAndSaveData()">获取并保存数据</button>
        <div id="message" style="margin-top:15px;"></div>
        <div id="response_text" class="error-details" style="display:none;"></div>


        <h2>已保存的文件</h2>
        <button onclick="listSavedFiles()">刷新列表</button>
        <p>数据保存路径: <code id="data_folder_path"></code></p>
        <ul id="saved_files_list" class="file-list"></ul>
    </div>

    <script>
        const coinNameSelect = document.getElementById('coin_name');
        const coinNameOtherInput = document.getElementById('coin_name_other');
        const messageDiv = document.getElementById('message');
        const fetchButton = document.getElementById('fetchButton');
        const fetchFngCheckbox = document.getElementById('fetch_historical_fng');
        const timePeriodSelect = document.getElementById('time_period');
        
        // Binance K-line interval mapping
        const binanceIntervalMap = {
            '1m': '1m', '15m': '15m', '1h': '1h', '4h': '4h', '1d': '1d', '1w': '1w'
        };

        // Show/hide manual coin input
        if (coinNameSelect) {
            coinNameSelect.addEventListener('change', function() {
                if (coinNameOtherInput) {
                    if (this.value === 'OTHER') {
                        coinNameOtherInput.style.display = 'block';
                        coinNameOtherInput.value = '';
                        coinNameOtherInput.focus();
                    } else {
                        coinNameOtherInput.style.display = 'none';
                    }
                }
            });
        }

        // Validate FNG checkbox based on time period
        if (timePeriodSelect && fetchFngCheckbox) {
            timePeriodSelect.addEventListener('change', function() {
                const selectedTimePeriod = this.value;
                const isFngCompatible = ['1d', '1w'].includes(selectedTimePeriod);
                fetchFngCheckbox.disabled = !isFngCompatible;
                if (!isFngCompatible) {
                    fetchFngCheckbox.checked = false;
                }
                const fngLabel = document.querySelector('label[for="fetch_historical_fng"]');
                if (fngLabel) {
                    if (isFngCompatible) {
                        fngLabel.textContent = '获取历史恐惧贪婪指数 (适用于 1d, 1w)';
                    } else {
                        fngLabel.textContent = '获取历史恐惧贪婪指数 (仅支持 1d, 1w)';
                    }
                }
            });
        }
        
        // Fetch Binance K-lines (supports batch requests for >1000 limit)
        async function fetchBinanceKlines(symbol, interval, startTime, endTime) {
            let klines = [];
            let fetchStart = startTime;
            const limit = 1000;
            while (true) {
                const url = `https://api.binance.com/api/v3/klines?symbol=${symbol}&interval=${interval}&startTime=${fetchStart}&endTime=${endTime}&limit=${limit}`;
                try {
                    const resp = await fetch(url);
                    if (!resp.ok) {
                        const errorData = await resp.text();
                        throw new Error(`Binance API 请求失败: ${resp.status} ${resp.statusText}. Response: ${errorData}`);
                    }
                    const data = await resp.json();
                    if (!Array.isArray(data)) {
                         throw new Error('Binance API 返回数据格式错误,期望一个数组。');
                    }
                    if (data.length === 0) break; // No more data or initial empty response
                    
                    klines = klines.concat(data);
                    if (data.length < limit) break; // Fetched less than limit, so it's the last batch
                    
                    fetchStart = data[data.length - 1][0] + 1; // Next start time is after the last fetched kline's open time
                } catch (error) {
                    console.error("Error during Binance kline fetch iteration:", error);
                    throw error; // Re-throw to be caught by the main function
                }
            }
            return klines;
        }

        // Fetch Fear and Greed Index data
        async function fetchFearAndGreedIndex(startDate, endDate) {
    try {
        const url = `https://api.alternative.me/fng/?limit=0&date_format=cn`;
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`获取恐惧贪婪指数失败: ${response.status}`);
        }
        
        const data = await response.json();
        if (!data || !data.data || !Array.isArray(data.data)) {
            throw new Error('恐惧贪婪指数数据格式无效');
        }
        
        // Parse the dates and set to start/end of day to include all data for those days
        const startDateObj = new Date(startDate);
        startDateObj.setHours(0, 0, 0, 0);
        const startTimestampMs = startDateObj.getTime();
        
        const endDateObj = new Date(endDate);
        endDateObj.setHours(23, 59, 59, 999); // Include the entire end date
        const endTimestampMs = endDateObj.getTime();

        if (isNaN(startTimestampMs) || isNaN(endTimestampMs)) {
            console.error("无效的FNG日期范围:", startDate, endDate);
            return [];
        }
        
        console.log(`Filtering FNG data between ${startDateObj.toISOString()} (${startTimestampMs}) and ${endDateObj.toISOString()} (${endTimestampMs})`);
        
        // Process all data points
        const filteredData = data.data.map(item => {
            // Parse the date string from the API (format: "YYYY-MM-DD")
            const [year, month, day] = item.timestamp.split('-').map(Number);
            const itemDate = new Date(year, month - 1, day);
            const itemTimestampMs = itemDate.getTime();
            
            return {
                ...item,
                _parsedDate: itemDate,
                _timestampMs: itemTimestampMs
            };
        }).filter(item => {
            return item._timestampMs >= startTimestampMs && item._timestampMs <= endTimestampMs;
        });
        
        console.log(`Filtered ${data.data.length} FNG data points to ${filteredData.length} in range`);
        
        return filteredData
            .map(item => {
                // Parse the date string to create a proper Date object
                const [year, month, day] = item.timestamp.split('-').map(Number);
                const itemDate = new Date(year, month - 1, day);
                
                return {
                    timestamp: itemDate.getTime(), // Convert to milliseconds
                    value: parseInt(item.value, 10),
                    value_classification: item.value_classification,
                    date: item.timestamp // Keep the original date string in YYYY-MM-DD format
                };
            });
    } catch (error) {
        console.error('获取恐惧贪婪指数时出错:', error);
        return []; 
    }
}

        async function fetchAndSaveData() {
            if (messageDiv) {
                messageDiv.textContent = '正在初始化请求...';
                messageDiv.className = 'info';
            }
            if (fetchButton) fetchButton.disabled = true;
            
            let fngData = [];
            const selectedTimePeriod = timePeriodSelect ? timePeriodSelect.value : '1d';
            const shouldFetchFng = fetchFngCheckbox ? fetchFngCheckbox.checked : false;
            const startDateInput = document.getElementById('start_date');
            const startDateValue = startDateInput ? startDateInput.value : new Date().toISOString().split('T')[0]; // Default to today if input not found
            const isFngCompatiblePeriod = ['1d', '1w'].includes(selectedTimePeriod);

            if (isFngCompatiblePeriod && shouldFetchFng) {
                if (messageDiv) messageDiv.textContent = '正在获取恐惧贪婪指数数据...';
                try {
                    // FNG is daily, so fetch from start_date up to "today" or a reasonable recent end date.
                    // If K-lines go far into the past, FNG API might not have data for all of it.
                    const todayStr = new Date().toISOString().split('T')[0];
                    fngData = await fetchFearAndGreedIndex(startDateValue, todayStr);
                    console.log('Fetched FNG data:', fngData);
                    if (fngData.length === 0) {
                        console.warn("未获取到恐惧贪婪指数数据,或指定日期范围内无数据。");
                    }
                } catch (errorCaughtByFetchAndSave) { // Catch errors if fetchFearAndGreedIndex re-throws
                    console.error('获取FNG数据处理中捕获到错误:', errorCaughtByFetchAndSave);
                    if (messageDiv) {
                        messageDiv.textContent = '获取恐惧贪婪指数失败: ' + errorCaughtByFetchAndSave.message + '。将尝试仅保存K线数据。';
                        messageDiv.className = 'info'; // Not a critical error for K-line saving
                    }
                    fngData = []; // Ensure it's an empty array to proceed
                }
            } else if (shouldFetchFng && !isFngCompatiblePeriod) {
                 console.warn(`FNG 获取已勾选,但时间周期 ${selectedTimePeriod} 不兼容 (仅支持 1d, 1w)。`);
                 if (messageDiv) {
                    messageDiv.textContent = `恐惧贪婪指数获取仅支持1d或1w周期。当前周期: ${selectedTimePeriod}。将仅保存K线数据。`;
                    messageDiv.className = 'info';
                }
            }

            try {
                let coinNameValue = coinNameSelect ? coinNameSelect.value : '';
                if (coinNameValue === 'OTHER' && coinNameOtherInput) {
                    coinNameValue = coinNameOtherInput.value.trim();
                }
                if (!coinNameValue) {
                    throw new Error("币种名称不能为空。");
                }

                // Get exchange name from input or default to 'binance'
                const exchangeInput = document.getElementById('exchange_name');
                const exchangeNameValue = exchangeInput ? exchangeInput.value.trim().toLowerCase() : 'binance';
                if (exchangeNameValue !== 'binance') {
                    console.warn("当前K线获取实现仅针对币安优化。其他交易所可能需要调整API调用。");
                    // For now, we'll proceed assuming it's 'binance' for the API call
                }

                const binanceSymbol = coinNameValue.replace('/', '').toUpperCase();
                const klineInterval = binanceIntervalMap[selectedTimePeriod] || '1d'; // Default to '1d' if not in map

                const klinesStartTimestamp = new Date(startDateValue).getTime();
                const klinesEndTimestamp = Date.now(); // Fetch up to current time

                if (messageDiv) messageDiv.textContent = '正在获取K线数据...';
                const klinesRaw = await fetchBinanceKlines(binanceSymbol, klineInterval, klinesStartTimestamp, klinesEndTimestamp);
                
                if (!klinesRaw || klinesRaw.length === 0) {
                    if (messageDiv) {
                        messageDiv.textContent = `未获取到 ${coinNameValue} (${klineInterval}) 的K线数据。`;
                        messageDiv.className = 'info'; // Not an error, but no data
                    }
                    if (fetchButton) fetchButton.disabled = false;
                    return; // Stop if no klines
                }
                console.log(`Fetched ${klinesRaw.length} raw klines for ${binanceSymbol}`);

                // Create a map of date to FNG data for faster lookup
                const fngDataMap = new Map();
                if (isFngCompatiblePeriod && shouldFetchFng && fngData.length > 0) {
                    fngData.forEach(fng => {
                        fngDataMap.set(fng.date, fng);
                    });
                    console.log(`FNG data available for ${fngData.length} dates`);
                }

                const formattedKlines = klinesRaw.map(arr => {
                    const klineTimestamp = arr[0]; // Binance K-line open time (milliseconds)
                    const klineDate = new Date(klineTimestamp);
                    // Format date consistently as YYYY-MM-DD for matching
                    const klineDateStr = klineDate.toISOString().split('T')[0];
                    
                    const klineObj = {
                        timestamp: klineTimestamp,
                        open: parseFloat(arr[1]),
                        high: parseFloat(arr[2]),
                        low: parseFloat(arr[3]),
                        close: parseFloat(arr[4]),
                        volume: parseFloat(arr[5]),
                        fng_value: null,
                        fng_classification: null
                    };
                    
                    if (isFngCompatiblePeriod && shouldFetchFng && fngDataMap.size > 0) {
                        const fngMatch = fngDataMap.get(klineDateStr);
                        if (fngMatch) {
                            klineObj.fng_value = fngMatch.value;
                            klineObj.fng_classification = fngMatch.value_classification;
                        } else {
                            console.warn(`No FNG data found for date: ${klineDateStr}`);
                        }
                    }
                    return klineObj;
                });
                
                console.log('Formatted klines with FNG (sample):', formattedKlines.slice(0, 3));

                const payload = {
                    symbol: coinNameValue,
                    interval: klineInterval,
                    klines: formattedKlines,
                    exchange: exchangeNameValue, // Send the actual exchange name used
                    fetch_historical_fng: (isFngCompatiblePeriod && shouldFetchFng) // Accurate flag
                };

                if (messageDiv) messageDiv.textContent = '正在发送数据到后端保存...';
                const endpoint = '/api/fetch_save_data';
                const response = await fetch(endpoint, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify(payload)
                });

                const result = await response.json();
                
                if (response.ok && result.success) {
                    if (messageDiv) {
                        messageDiv.textContent = result.message || '数据保存成功!';
                        messageDiv.className = 'success';
                        if (result.fear_greed_index_included) {
                            const fngFoundCount = result.fear_greed_values_found || 0;
                            if (fngFoundCount > 0) {
                                messageDiv.textContent += ` 其中 ${fngFoundCount} 条记录包含恐惧贪婪指数。`;
                            } else {
                                messageDiv.textContent += ` 已尝试获取恐惧贪婪指数,但未匹配到数据。`;
                            }
                        }
                    }
                    listSavedFiles();
                } else {
                    const errorMsg = result.error || result.message || (response.status ? `HTTP ${response.status}` : '未知后端错误');
                    const details = result.details || (result.traceback ? "详细信息见控制台。" : "");
                    throw new Error(`后端保存失败: ${errorMsg} ${details}`);
                }
            } catch (e) { // Catches errors from kline fetch, FNG processing, or backend communication
                if (messageDiv) {
                    let errorMessage = '操作失败: ' + e.message;
                    if (e.stack) {
                        console.error('Error stack:', e.stack);
                    }
                    if (e.message && e.message.includes("traceback")) {
                        console.error('Backend error details:', e.message);
                    }
                    messageDiv.textContent = errorMessage;
                    messageDiv.className = 'error';
                }
                console.error('Error in fetchAndSaveData main try block:', e);
            } finally {
                if (fetchButton) fetchButton.disabled = false;
            }
        }

        async function listSavedFiles() {
            const listElement = document.getElementById('saved_files_list');
            const pathElement = document.getElementById('data_folder_path');
            if (!listElement || !pathElement) {
                console.error("listSavedFiles: DOM elements for file list not found.");
                return;
            }

            listElement.innerHTML = '<li>正在加载文件列表...</li>';
            pathElement.textContent = '加载中...';
            try {
                const response = await fetch('/list-saved-files');
                 if (!response.ok) {
                     const errorText = await response.text();
                     throw new Error(`HTTP error ${response.status}: ${errorText}`);
                 }
                const result = await response.json();
                if (result.success) {
                    pathElement.textContent = result.data_folder_path || '未知路径';
                    if (result.files && result.files.length > 0) {
                        listElement.innerHTML = result.files.map(f => `<li>${f}</li>`).join('');
                    } else {
                        listElement.innerHTML = `<li>目前没有已保存的文件。(路径: ${result.data_folder_path || '未知'})</li>`;
                    }
                } else {
                    throw new Error(result.error || '加载文件列表失败 (后端报告)');
                }
            } catch (error) {
                listElement.innerHTML = `<li>加载文件列表时发生错误: ${error.message}</li>`;
                pathElement.textContent = '获取路径失败';
                console.error("List files error:", error);
            }
        }

        // Initial setup when DOM is ready
        document.addEventListener('DOMContentLoaded', () => {
            if (timePeriodSelect) {
                timePeriodSelect.dispatchEvent(new Event('change')); // Set initial FNG checkbox state
            }
            if (coinNameSelect && coinNameSelect.value === 'OTHER' && coinNameOtherInput) {
                coinNameOtherInput.style.display = 'block'; // Handle pre-selected "OTHER"
            }
            listSavedFiles(); // Load files when page loads
        });
    </script>
</body>
</html>

app.py

import os
import pandas as pd
from flask import Flask, render_template, request, jsonify
from flask_cors import CORS
from datetime import datetime, date
import traceback


# --- 全局配置 ---
DATA_FOLDER = 'crypto_data_received'
if not os.path.exists(DATA_FOLDER):
    os.makedirs(DATA_FOLDER)


app = Flask(__name__)
CORS(app)  # Enable CORS for all routes

# --- Flask 路由 ---
@app.route('/')
def index_page():
    coins_available = [
        "BTC", "ETH", "XRP", "SOL", "XLM", "DOGE", "ALGO", "ADA", "MATIC", "LINK", "UNI", "SUSHI"
    ]
    default_start_date = "2018-02-01"
    today_date = date.today().strftime('%Y-%m-%d')

    return render_template(
        'index.html',
        coins_available=coins_available,
        default_start_date=default_start_date,
        today_date=today_date
    )



@app.route('/api/fetch_save_data', methods=['POST'])
def fetch_and_save_data():
    print("\n=== 收到 /api/fetch_save_data 请求 ===")
    print(f"请求头: {request.headers}")
    print(f"请求内容类型: {request.content_type}")
    
    try:
        data = request.get_json()
        if not data:
            return jsonify({"success": False, "error": "No data provided"}), 400

        # 获取参数
        symbol = data.get('symbol')
        interval = data.get('interval', '1d')
        # Klines now expected to be a list of dictionaries from frontend
        klines_list_of_dicts = data.get('klines', [])
        exchange_name = data.get('exchange', 'binance').lower()

        # 验证必要参数
        if not symbol or not interval: # klines can be empty if no data was fetched
            return jsonify({
                'success': False,
                'error': 'Missing required parameters: symbol, interval'
            }), 400

        if not klines_list_of_dicts:
             message = f"收到 {symbol} ({interval}) 空数据,未保存文件。"
             print(f"ℹ️ {message}")
             return jsonify({
                 'success': True,
                 'message': message,
                 'filename': None,
                 'filepath': None,
                 'records_count': 0
             })

        # --- MODIFICATION START ---
        # Frontend sends klines as a list of dictionaries.
        # We should infer the columns from the first dictionary.
        if not isinstance(klines_list_of_dicts[0], dict):
             # This indicates an unexpected format from frontend
             error_msg = 'Unexpected klines data format: not a list of dictionaries?'
             print(f"❌ {error_msg}")
             return jsonify({
                 'success': False,
                 'error': error_msg,
                 'details': 'Received klines data is not in the expected list of dictionary format.'
             }), 400

        # Get column names from the keys of the first dictionary
        columns = list(klines_list_of_dicts[0].keys())
        print(f"ℹ️ Inferred columns from received data: {columns}")


        # Create DataFrame directly from the list of dictionaries
        # Pandas will use the keys in the dictionaries as column names, matching the inferred columns.
        df = pd.DataFrame(klines_list_of_dicts, columns=columns)

        if 'timestamp' in df.columns:
            try:
                df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
                df.set_index('timestamp', inplace=True)
                # Sort by index (timestamp)
                df = df.sort_index()
            except Exception as e:
                print(f"⚠️ Warning: Timestamp conversion or index setting failed: {e}. Proceeding without timestamp index.")
                # Optionally remove the original timestamp column if setting index failed
                if 'timestamp' in df.columns:
                    try:
                         df.drop(columns=['timestamp'], inplace=True)
                    except KeyError: # Handle case where drop fails
                         pass

        # Ensure numeric columns are correct types
        # Pandas usually does this well from dicts, but explicit coercion can be safer
        for col in ['open', 'high', 'low', 'close', 'volume', 'fng_value']:
            if col in df.columns:
                # Coerce to numeric, invalid parsing becomes NaN
                df[col] = pd.to_numeric(df[col], errors='coerce')

        # Create data directory if it doesn't exist
        if not os.path.exists(DATA_FOLDER):
            os.makedirs(DATA_FOLDER)

        # Generate filename
        # Use safe names based on symbol and exchange
        safe_symbol = symbol.replace('/', '-').replace(':', '-')
        safe_exchange_name = exchange_name # exchange_name is already lower()
        # Remove potentially problematic characters
        safe_symbol = "".join(c for c in safe_symbol if c.isalnum() or c in ['_','-','.'])
        safe_exchange_name = "".join(c for c in safe_exchange_name if c.isalnum() or c in ['_','-','.'])

        # Ensure file name doesn't start with '.' or '_' after cleaning, prevent empty strings
        while safe_symbol.startswith('.') or safe_symbol.startswith('_'): safe_symbol = safe_symbol[1:]
        while safe_exchange_name.startswith('.') or safe_exchange_name.startswith('_'): safe_exchange_name = safe_exchange_name[1:]
        if not safe_symbol: safe_symbol = "unknown_coin"
        if not safe_exchange_name: safe_exchange_name = "unknown_exchange"


        start_date_str = df.index.min().strftime('%Y%m%d') if not df.empty and isinstance(df.index, pd.DatetimeIndex) else 'nodata_start'
        end_date_str = df.index.max().strftime('%Y%m%d') if not df.empty and isinstance(df.index, pd.DatetimeIndex) else 'nodata_end'
        current_time_str = datetime.now().strftime('%Y%m%d%H%M%S')

        # Filename format: exchange_SYMBOL_INTERVAL_startDATE_to_endDATE_TIMESTAMP.csv
        filename = f"{safe_exchange_name}_{safe_symbol}_{interval}_{start_date_str}_to_{end_date_str}_{current_time_str}.csv"
        filepath = os.path.join(DATA_FOLDER, filename)

        # Save to CSV
        # Save with index=True if timestamp index is set, otherwise index=False
        df.to_csv(filepath, index=isinstance(df.index, pd.DatetimeIndex))

        message = f"成功保存 {symbol} ({len(df)} 条记录) 数据到: {filename}"
        print(f"✅ {message}")

        response_data = {
            'success': True,
            'message': message,
            'filename': filename,
            'filepath': filepath,
            'records_count': len(df),
            'columns_saved': list(df.columns) # Include saved columns in response
        }

        # Provide info about FNG data inclusion
        if 'fng_value' in df.columns:
            response_data['fear_greed_index_included'] = True
            non_na_fng_count = df['fng_value'].notna().sum() # Count non-NA values in fng_value column
            response_data['fear_greed_values_found'] = int(non_na_fng_count)
            fng_series_dropna = df['fng_value'].dropna()
            if not fng_series_dropna.empty:
                 # Use float for sample to handle potential NaN after coercion
                 response_data['fear_greed_index_sample'] = float(fng_series_dropna.iloc[0])
            else:
                 response_data['fear_greed_index_sample'] = "N/A"
        else:
            response_data['fear_greed_index_included'] = False


        return jsonify(response_data)

    except Exception as e:
        error_trace = traceback.format_exc()
        print(f"❌ /api/fetch_save_data 接口发生错误: {e}")
        print(error_trace)
        return jsonify({
            'success': False,
            'error': '服务器内部错误',
            'details': str(e),
            'traceback': error_trace # Include traceback for debugging
        }), 500

@app.route('/list-saved-files', methods=['GET'])
def list_saved_files():
    try:
        files_with_mtime = []
        # Check if DATA_FOLDER exists before listing
        if not os.path.exists(DATA_FOLDER):
             os.makedirs(DATA_FOLDER, exist_ok=True) # Create if missing
             # If folder is just created or empty, return empty list
             return jsonify({"success": True, "files": [], "data_folder_path": os.path.abspath(DATA_FOLDER), "message": "数据文件夹刚创建或为空,目前无文件。"}), 200


        # Use a more robust way to list files, ignoring hidden files and non-csv
        all_items = os.listdir(DATA_FOLDER)
        valid_files = [f for f in all_items if os.path.isfile(os.path.join(DATA_FOLDER, f)) and f.lower().endswith('.csv') and not f.startswith('.')] # Ignore hidden files

        for f_name in valid_files:
            f_path = os.path.join(DATA_FOLDER, f_name)
            try:
                mtime = os.path.getmtime(f_path)
                files_with_mtime.append((f_name, mtime))
            except Exception as e:
                print(f"⚠️ 获取文件修改时间失败 {f_name}: {e}")
                files_with_mtime.append((f_name, 0)) # Add with time 0, will appear at the end


        # Sort by modification time, newest first
        files_with_mtime.sort(key=lambda x: x[1], reverse=True)
        sorted_files = [f[0] for f in files_with_mtime]

        return jsonify({"success": True, "files": sorted_files, "data_folder_path": os.path.abspath(DATA_FOLDER)})
    except Exception as e:
        print(f"❌ 获取文件列表失败: {e}")
        return jsonify({"success": False, "error": "获取文件列表失败: " + str(e)}), 500

if __name__ == '__main__':
    print(f"🌍 Crypto Data Saver 服务已启动,请在浏览器打开 http://127.0.0.1:5000/")
    print(f"💾 接收到的数据将保存在: {os.path.abspath(DATA_FOLDER)}")
    print(f"🔧 依赖说明: 需要安装 pandas 和 requests (pip install pandas requests)") 
    print(f"ℹ️  此服务等待前端通过POST请求到 /api/fetch_save_data 保存数据。")
    print(f"🚀  推荐使用 index.html 中的按钮触发 /api/fetch_save_data 流程。")
    app.run(debug=True, use_reloader=False) # use_reloader=False to avoid running startup prints twice

运行:

python app.py
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容