chore: sync local changes

This commit is contained in:
boris
2026-04-22 14:45:59 +08:00
parent 44bcdef920
commit 5815379da2
22 changed files with 67512 additions and 1 deletions

View File

@@ -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")
);
}
}