shindo_coding_utils 0.3.1

A utils crates which will be used in various micro-services
Documentation
use chrono::{DateTime, Duration, NaiveDate, Utc};
use serde::Serialize;

use crate::app_state::AppState;
use crate::models::IntradayTrading;
use crate::repositories::historical_data::HistoricalDataSearchOptions;
use crate::repositories::intraday_trading::FindOptions;
use crate::schemas::historical_data::Model as HistoricalDataModel;
use crate::types::SortOrder;

#[derive(Debug, Clone, Serialize)]
pub struct SimilarCaseSummary {
    pub date: String,
    pub price_at_signal: f64,
    pub return_1d: f64,
    pub return_2d: f64,
    pub return_3d: f64,
    pub trend_status: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SignalResult {
    pub score: f64,      // Xác suất thắng 0..1
    pub avg_return: f64, // Lợi nhuận TB
    pub n: usize,        // Số mẫu
    pub trend_status: String,
    pub volatility_status: String,
    pub explain: String,
    pub similar_cases: Vec<SimilarCaseSummary>,
}

/// date: "YYYY-MM-DD"
/// side: "buy" hoặc "sell" (lowercase)
pub async fn analyze_big_order_signal(
    app_state: &AppState,
    ticker: &str,
    date: &str, // "YYYY-MM-DD"
    volume: i64,
    side: &str, // "buy" or "sell"
) -> Result<SignalResult, String> {
    // 1. Truy vấn lệnh lớn quá khứ (top 5-10% các lệnh trong gần 1 năm, cùng side)
    // Giả định volume lớn nếu > 2.5 std của volume intraday cùng ticker/side trong lịch sử gần
    let date_naive = NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|_| "Invalid date")?;
    let date_dt = DateTime::<Utc>::from_utc(date_naive.and_hms(0, 0, 0), Utc);
    let lookback_days = 90;
    let history_start = date_dt - Duration::days(lookback_days);
    let intraday_trading_repo = app_state.intraday_trading_repository.clone();

    // a. Lấy volume trung bình và stddev của lệnh (trong 1 năm)
    let find_options = FindOptions {
        ticker: ticker.to_string(),
        timerange: crate::types::TimeRange {
            start: history_start,
            end: date_dt,
        },
    };

    let orders = intraday_trading_repo
        .find(find_options)
        .await
        .map_err(|e| e.to_string())?;
    let vols: Vec<i64> = orders.iter().map(|o| o.last_vol).collect();
    if vols.is_empty() {
        return Err("Không đủ dữ liệu lịch sử để so sánh".to_string());
    }
    let avg_vol = vols.iter().cloned().sum::<i64>() as f64 / vols.len() as f64;
    let std_vol = (vols
        .iter()
        .map(|&v| ((v as f64 - avg_vol).powi(2)))
        .sum::<f64>()
        / vols.len() as f64)
        .sqrt();
    let big_threshold = avg_vol + 2.5 * std_vol;
    let mut similar_cases: Vec<&IntradayTrading> = orders
        .iter()
        .filter(|o| o.last_vol as f64 >= big_threshold)
        .collect();
    // Bổ sung thêm case cực lớn tương tự volume hiện tại
    similar_cases.extend(
        orders.iter().filter(|o| {
            (o.last_vol - volume).abs() < std_vol as i64 && o.last_vol > (avg_vol as i64)
        }),
    );
    // Loại duplicate
    similar_cases.sort_by(|a, b| a.trading_timestamp.cmp(&b.trading_timestamp));
    similar_cases.dedup_by(|a, b| a.trading_timestamp == b.trading_timestamp);

    // Lưu các case summary để xuất explain
    let mut analyzed_cases = Vec::new();
    let mut win_total = 0;
    let mut total_return = 0.0;

    for c in similar_cases.iter().take(30) {
        let case_date = c.trading_timestamp.date_naive().to_string();
        let price_at_signal = c.last_price as f64;
        // Lấy giá close T+1 T+2 T+3
        let prices = get_next_n_close_prices(app_state.clone(), ticker, &case_date, 3)
            .await
            .unwrap_or(vec![]);
        let (mut ret_1, mut ret_2, mut ret_3) = (0.0, 0.0, 0.0);
        if prices.len() > 0 {
            ret_1 = (prices[0] as f64 / price_at_signal) - 1.0;
        }
        if prices.len() > 1 {
            ret_2 = (prices[1] as f64 / price_at_signal) - 1.0;
        }
        if prices.len() > 2 {
            ret_3 = (prices[2] as f64 / price_at_signal) - 1.0;
        }
        // Thắng với big_buy nếu return > 0, big_sell thì return < 0
        let win = match side {
            "buy" => {
                if ret_2 > 0.0 {
                    1
                } else {
                    0
                }
            }
            "sell" => {
                if ret_2 < 0.0 {
                    1
                } else {
                    0
                }
            }
            _ => 0,
        };
        total_return += ret_2;
        win_total += win;
        analyzed_cases.push(SimilarCaseSummary {
            date: case_date,
            price_at_signal,
            return_1d: ret_1,
            return_2d: ret_2,
            return_3d: ret_3,
            trend_status: "TODO".to_string(), // Có thể detect thêm
        });
    }
    let n = analyzed_cases.len();
    if n == 0 {
        return Err("Không đủ case tương tự để kết luận".to_string());
    }
    // b. Tính score, avg_return
    let mut score = win_total as f64 / n as f64;
    let avg_return = total_return / n as f64;

    // c. Detect trend & volatility
    let hist = get_recent_historical(app_state.clone(), ticker, date, 21).await;
    let trend_status = detect_trend(&hist);
    let volatility_status = detect_volume_anomaly(volume, &hist);

    // d. Điều chỉnh score (ví dụ: breakout uptrend nâng score, panic giảm)
    if trend_status.contains("tăng mạnh") && side == "buy" {
        score = (score * 1.1).min(1.0);
    }
    if trend_status.contains("giảm mạnh") && side == "sell" {
        score = (score * 1.1).min(1.0);
    }

    // e. Compose giải thích
    let explain = make_explain(
        score,
        avg_return,
        n,
        &trend_status,
        &volatility_status,
        &analyzed_cases,
    );

    Ok(SignalResult {
        score,
        avg_return,
        n,
        trend_status,
        volatility_status,
        explain,
        similar_cases: analyzed_cases.into_iter().take(5).collect(), // Chỉ lấy 5 case nổi bật
    })
}

// Lấy giá close 3 ngày tiếp theo sau 1 date
async fn get_next_n_close_prices(
    app_state: AppState,
    ticker: &str,
    start_date: &str,
    n: usize,
) -> Option<Vec<i64>> {
    let dates = (1..=n)
        .map(|i| {
            NaiveDate::parse_from_str(start_date, "%Y-%m-%d")
                .ok()
                .map(|d| (d + Duration::days(i as i64)).to_string())
        })
        .collect::<Vec<_>>();
    let mut prices = Vec::new();
    let historical_repo = &app_state.historical_data_repository;
    for d in dates.iter().flatten() {
        let search_options = HistoricalDataSearchOptions {
            symbol: ticker.to_string(),
            end_date: d.to_string(),
            limit: 1,
            order: SortOrder::Ascending,
        };
        let record = historical_repo
            .get_historical_data_by_symbol_and_time(search_options)
            .await;
        let record = match record {
            Ok(mut recs) => recs.pop(),
            Err(_) => None,
        };
        if let Some(rec) = record {
            prices.push(rec.price_close);
        }
    }
    if prices.is_empty() {
        None
    } else {
        Some(prices)
    }
}

// Lấy historical 21 phiên gần nhất để phân tích xu hướng, volume
async fn get_recent_historical(
    app_state: AppState,
    ticker: &str,
    date: &str,
    n: usize,
) -> Vec<HistoricalDataModel> {
    let till = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
    let historical_repo = &app_state.historical_data_repository;
    let options = HistoricalDataSearchOptions {
        symbol: ticker.to_string(),
        end_date: till.to_string(),
        limit: n as u64,
        order: SortOrder::Descending,
    };
    let rows = historical_repo
        .get_historical_data_by_symbol_and_time(options)
        .await;

    match rows {
        Ok(data) => data,
        Err(_) => vec![],
    }
}

// Hàm detect trend (up/down/sideways)
fn detect_trend(hist: &Vec<HistoricalDataModel>) -> String {
    if hist.len() < 10 {
        return "Không đủ data trend".to_string();
    }
    let closes: Vec<f64> = hist.iter().map(|h| h.price_close as f64).collect();
    let first = closes.last().unwrap();
    let last = closes.first().unwrap();
    let pct = (last / first - 1.0) * 100.0;
    if pct > 5.0 {
        "Đang tăng mạnh trong 20 phiên".to_string()
    } else if pct < -5.0 {
        "Đang giảm mạnh trong 20 phiên".to_string()
    } else {
        "Xu hướng sideway/ít biến động 20 phiên".to_string()
    }
}

// Hàm check volume anomaly
fn detect_volume_anomaly(volume: i64, hist: &Vec<HistoricalDataModel>) -> String {
    if hist.len() < 5 {
        return "Không đủ data volume".to_string();
    }
    let vs: Vec<i64> = hist.iter().map(|h| h.total_volume).collect();
    let avg = vs.iter().cloned().sum::<i64>() as f64 / vs.len() as f64;
    let std =
        (vs.iter().map(|&v| ((v as f64 - avg).powi(2))).sum::<f64>() / vs.len() as f64).sqrt();
    if volume as f64 > avg + 2.5 * std {
        format!(
            "Đột biến, lớn gấp {:.1} lần volume TB 20 phiên",
            volume as f64 / avg
        )
    } else if volume as f64 > avg + 1.5 * std {
        "Khá lớn so với TB 20 phiên".to_string()
    } else {
        "Volume không quá lớn so với nền".to_string()
    }
}

fn make_explain(
    score: f64,
    avg_return: f64,
    n: usize,
    trend: &str,
    vol: &str,
    _cases: &Vec<SimilarCaseSummary>,
) -> String {
    let mut s = format!(
        "Tín hiệu score {:.0}% ({} samples). Lợi nhuận TB 2 ngày: {:.2}%. {}. {}.",
        score * 100.0,
        n,
        avg_return * 100.0,
        trend,
        vol
    );
    if n < 10 {
        s.push_str(" Lưu ý: độ tin cậy thấp do số mẫu ít!");
    }
    s
}