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, pub avg_return: f64, pub n: usize, pub trend_status: String,
pub volatility_status: String,
pub explain: String,
pub similar_cases: Vec<SimilarCaseSummary>,
}
pub async fn analyze_big_order_signal(
app_state: &AppState,
ticker: &str,
date: &str, volume: i64,
side: &str, ) -> Result<SignalResult, String> {
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();
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();
similar_cases.extend(
orders.iter().filter(|o| {
(o.last_vol - volume).abs() < std_vol as i64 && o.last_vol > (avg_vol as i64)
}),
);
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);
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;
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;
}
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(), });
}
let n = analyzed_cases.len();
if n == 0 {
return Err("Không đủ case tương tự để kết luận".to_string());
}
let mut score = win_total as f64 / n as f64;
let avg_return = total_return / n as f64;
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);
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);
}
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(), })
}
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)
}
}
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![],
}
}
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()
}
}
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
}