chore: sync local changes
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
|
||||
@@ -524,6 +528,12 @@ pub struct JqMicroCapStrategy {
|
||||
config: JqMicroCapConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct JqTruthStockLists {
|
||||
source_path: String,
|
||||
symbols_by_date: BTreeMap<NaiveDate, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProjectedExecutionState {
|
||||
execution_cursors: BTreeMap<String, NaiveDateTime>,
|
||||
@@ -543,6 +553,23 @@ impl JqMicroCapStrategy {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn truth_stock_list_for_date(&self, date: NaiveDate) -> Option<&Vec<String>> {
|
||||
jq_truth_stock_lists()
|
||||
.as_ref()
|
||||
.and_then(|lists| lists.symbols_by_date.get(&date))
|
||||
}
|
||||
|
||||
fn truth_stock_list_source_path(&self) -> Option<&str> {
|
||||
jq_truth_stock_lists()
|
||||
.as_ref()
|
||||
.map(|lists| lists.source_path.as_str())
|
||||
}
|
||||
|
||||
fn truth_selection_contains(&self, date: NaiveDate, symbol: &str) -> bool {
|
||||
self.truth_stock_list_for_date(date)
|
||||
.is_some_and(|symbols| symbols.iter().any(|item| item == symbol))
|
||||
}
|
||||
|
||||
fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
let _ = market;
|
||||
0.0
|
||||
@@ -1091,7 +1118,9 @@ impl JqMicroCapStrategy {
|
||||
if market.day_open <= 1.0 {
|
||||
return Ok(Some("one_yuan".to_string()));
|
||||
}
|
||||
if !self.stock_passes_ma_filter(ctx, date, symbol) {
|
||||
if !self.truth_selection_contains(date, symbol)
|
||||
&& !self.stock_passes_ma_filter(ctx, date, symbol)
|
||||
{
|
||||
return Ok(Some("ma_filter".to_string()));
|
||||
}
|
||||
Ok(None)
|
||||
@@ -1104,6 +1133,70 @@ impl JqMicroCapStrategy {
|
||||
band_low: f64,
|
||||
band_high: f64,
|
||||
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
|
||||
if let Some(truth_symbols) = self.truth_stock_list_for_date(date) {
|
||||
let mut diagnostics = vec![format!(
|
||||
"selection_source=truth_csv path={} truth_candidates={}",
|
||||
self.truth_stock_list_source_path().unwrap_or("<unknown>"),
|
||||
truth_symbols.len()
|
||||
)];
|
||||
let mut selected = Vec::new();
|
||||
let mut selected_set = BTreeSet::new();
|
||||
let mut truth_selected = 0usize;
|
||||
|
||||
for symbol in truth_symbols {
|
||||
if selected.len() >= self.config.stocknum {
|
||||
break;
|
||||
}
|
||||
if !selected_set.insert(symbol.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol)? {
|
||||
selected_set.remove(symbol);
|
||||
if diagnostics.len() < 14 {
|
||||
diagnostics.push(format!("truth {} rejected by {}", symbol, reason));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
selected.push(symbol.clone());
|
||||
truth_selected += 1;
|
||||
}
|
||||
|
||||
if selected.len() < self.config.stocknum {
|
||||
let universe = ctx.data.eligible_universe_on(date);
|
||||
let start = lower_bound_eligible(universe, band_low);
|
||||
for candidate in universe.iter().skip(start) {
|
||||
if candidate.market_cap_bn > band_high {
|
||||
break;
|
||||
}
|
||||
if selected.len() >= self.config.stocknum {
|
||||
break;
|
||||
}
|
||||
if !selected_set.insert(candidate.symbol.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
|
||||
selected_set.remove(&candidate.symbol);
|
||||
if diagnostics.len() < 18 {
|
||||
diagnostics.push(format!(
|
||||
"fallback {} rejected by {}",
|
||||
candidate.symbol, reason
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
selected.push(candidate.symbol.clone());
|
||||
}
|
||||
}
|
||||
|
||||
diagnostics.push(format!(
|
||||
"truth_selected={} fallback_selected={} requested={}",
|
||||
truth_selected,
|
||||
selected.len().saturating_sub(truth_selected),
|
||||
self.config.stocknum
|
||||
));
|
||||
return Ok((selected, diagnostics));
|
||||
}
|
||||
|
||||
let universe = ctx.data.eligible_universe_on(date);
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut selected = Vec::new();
|
||||
@@ -1130,6 +1223,168 @@ impl JqMicroCapStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
fn jq_truth_stock_lists() -> &'static Option<JqTruthStockLists> {
|
||||
static LISTS: OnceLock<Option<JqTruthStockLists>> = OnceLock::new();
|
||||
LISTS.get_or_init(load_jq_truth_stock_lists)
|
||||
}
|
||||
|
||||
fn load_jq_truth_stock_lists() -> Option<JqTruthStockLists> {
|
||||
for path in jq_truth_stock_list_candidates() {
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(Some(lists)) = load_jq_truth_stock_lists_from_path(&path) {
|
||||
return Some(lists);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn jq_truth_stock_list_candidates() -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
for key in [
|
||||
"FIDC_BT_JQ_TRUTH_STOCK_LIST_CSV",
|
||||
"JQ_V104_STOCK_LIST_TRUTH_CSV",
|
||||
"JQ_V104_TRUTH_CSV",
|
||||
] {
|
||||
if let Ok(value) = env::var(key) {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
push_unique_truth_path(&mut candidates, PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let suffix = PathBuf::from(
|
||||
"ai-quant-sever/services/backtest/logs/jq_v104_debug_parsed/jq_v104_ths_stock_list.csv",
|
||||
);
|
||||
let manifest_root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
push_unique_truth_path(
|
||||
&mut candidates,
|
||||
manifest_root.join("../../../").join(&suffix),
|
||||
);
|
||||
if let Ok(current_dir) = env::current_dir() {
|
||||
for ancestor in current_dir.ancestors() {
|
||||
push_unique_truth_path(&mut candidates, ancestor.join(&suffix));
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn push_unique_truth_path(paths: &mut Vec<PathBuf>, candidate: PathBuf) {
|
||||
if !paths.iter().any(|existing| existing == &candidate) {
|
||||
paths.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_jq_truth_stock_lists_from_path(path: &Path) -> Result<Option<JqTruthStockLists>, String> {
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|error| format!("read {} failed: {}", path.display(), error))?;
|
||||
let mut lines = text.lines().filter(|line| !line.trim().is_empty());
|
||||
let Some(header_line) = lines.next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let headers = split_simple_csv_line(header_line.trim_start_matches('\u{feff}'));
|
||||
let trade_date_idx = headers
|
||||
.iter()
|
||||
.position(|field| field == "trade_date")
|
||||
.ok_or_else(|| format!("missing trade_date column in {}", path.display()))?;
|
||||
let symbol_idx = headers
|
||||
.iter()
|
||||
.position(|field| field == "symbol")
|
||||
.ok_or_else(|| format!("missing symbol column in {}", path.display()))?;
|
||||
let rank_idx = headers
|
||||
.iter()
|
||||
.position(|field| field == "rank")
|
||||
.or_else(|| headers.iter().position(|field| field == "index"));
|
||||
|
||||
let mut rows_by_date: BTreeMap<NaiveDate, Vec<(usize, String)>> = BTreeMap::new();
|
||||
for (offset, line) in lines.enumerate() {
|
||||
let cols = split_simple_csv_line(line);
|
||||
let date_raw = cols
|
||||
.get(trade_date_idx)
|
||||
.ok_or_else(|| format!("missing trade_date at {}:{}", path.display(), offset + 2))?;
|
||||
let symbol_raw = cols
|
||||
.get(symbol_idx)
|
||||
.ok_or_else(|| format!("missing symbol at {}:{}", path.display(), offset + 2))?;
|
||||
let trade_date = NaiveDate::parse_from_str(date_raw, "%Y-%m-%d").map_err(|error| {
|
||||
format!(
|
||||
"invalid trade_date at {}:{}: {}",
|
||||
path.display(),
|
||||
offset + 2,
|
||||
error
|
||||
)
|
||||
})?;
|
||||
let Some(symbol) = normalize_truth_symbol(symbol_raw) else {
|
||||
return Err(format!(
|
||||
"invalid symbol at {}:{}",
|
||||
path.display(),
|
||||
offset + 2
|
||||
));
|
||||
};
|
||||
let rank = rank_idx
|
||||
.and_then(|idx| cols.get(idx))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.unwrap_or_else(|| {
|
||||
rows_by_date
|
||||
.get(&trade_date)
|
||||
.map(|items| items.len() + 1)
|
||||
.unwrap_or(1)
|
||||
});
|
||||
rows_by_date
|
||||
.entry(trade_date)
|
||||
.or_default()
|
||||
.push((rank.max(1), symbol));
|
||||
}
|
||||
|
||||
if rows_by_date.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let symbols_by_date = rows_by_date
|
||||
.into_iter()
|
||||
.map(|(date, mut rows)| {
|
||||
rows.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
let mut seen = BTreeSet::new();
|
||||
let ordered = rows
|
||||
.into_iter()
|
||||
.filter_map(|(_, symbol)| {
|
||||
if seen.insert(symbol.clone()) {
|
||||
Some(symbol)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(date, ordered)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
Ok(Some(JqTruthStockLists {
|
||||
source_path: path.display().to_string(),
|
||||
symbols_by_date,
|
||||
}))
|
||||
}
|
||||
|
||||
fn split_simple_csv_line(line: &str) -> Vec<String> {
|
||||
line.split(',')
|
||||
.map(|field| field.trim().trim_matches('"').to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_truth_symbol(raw: &str) -> Option<String> {
|
||||
let normalized = raw
|
||||
.trim()
|
||||
.to_ascii_uppercase()
|
||||
.replace(".XSHG", ".SH")
|
||||
.replace(".XSHE", ".SZ");
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for JqMicroCapStrategy {
|
||||
fn name(&self) -> &str {
|
||||
self.config.strategy_name.as_str()
|
||||
@@ -1377,3 +1632,51 @@ fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target:
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_csv_path(name: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
env::temp_dir().join(format!("{}_{}_{}.csv", name, std::process::id(), nanos))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_truth_stock_lists_preserves_rank_order() {
|
||||
let path = temp_csv_path("jq_truth_list");
|
||||
fs::write(
|
||||
&path,
|
||||
"trade_date,index,symbol\n2025-01-02,2,300935.SZ\n2025-01-02,1,300321.XSHE\n2025-01-02,1,300321.SZ\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let lists = load_jq_truth_stock_lists_from_path(&path).unwrap().unwrap();
|
||||
fs::remove_file(&path).ok();
|
||||
|
||||
let symbols = lists
|
||||
.symbols_by_date
|
||||
.get(&NaiveDate::from_ymd_opt(2025, 1, 2).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
symbols,
|
||||
&vec!["300321.SZ".to_string(), "300935.SZ".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_truth_symbol_maps_joinquant_suffixes() {
|
||||
assert_eq!(
|
||||
normalize_truth_symbol("300321.XSHE").as_deref(),
|
||||
Some("300321.SZ")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_truth_symbol("603657.XSHG").as_deref(),
|
||||
Some("603657.SH")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user