use std::collections::HashSet;
use std::fs;
use std::path::Path;
use anyhow::{Context, Result, anyhow};
use czsc_core::objects::position::Position;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
pub fn unique_signals_across(positions: &[Position]) -> Vec<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut ordered: Vec<String> = Vec::new();
for pos in positions {
for event in pos.all_events() {
for signal in event
.signals_all
.iter()
.chain(event.signals_any.iter())
.chain(event.signals_not.iter())
{
let key = signal.to_string();
if seen.insert(key.clone()) {
ordered.push(key);
}
}
}
}
ordered
}
pub trait Strategy {
fn positions(&self) -> Vec<Position>;
fn unique_signals(&self) -> Vec<String> {
unique_signals_across(&self.positions())
}
}
#[derive(Debug, Clone)]
pub struct JsonStrategy {
pub symbol: String,
pub positions: Vec<Position>,
}
impl JsonStrategy {
pub fn from_files<I, P>(symbol: impl Into<String>, files: I, check: bool) -> Result<Self>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let symbol = symbol.into();
let mut positions = Vec::new();
for file in files {
let pos = load_position_from_file(file.as_ref(), &symbol, check)?;
positions.push(pos);
}
Ok(Self { symbol, positions })
}
}
impl Strategy for JsonStrategy {
fn positions(&self) -> Vec<Position> {
self.positions.clone()
}
}
const CHECKSUM_FIELD: &str = "checksum";
const LEGACY_MD5_FIELD: &str = "md5";
const STRIPPED_FIELDS_FOR_CHECKSUM: &[&str] = &[CHECKSUM_FIELD, LEGACY_MD5_FIELD];
pub fn save_position_to_file(pos: &Position, path: &Path) -> Result<()> {
let mut payload =
serde_json::to_value(pos).with_context(|| format!("序列化 Position 失败: {path:?}"))?;
{
let map = expect_object_mut(&mut payload, path)?;
map.remove("symbol");
for field in STRIPPED_FIELDS_FOR_CHECKSUM {
map.remove(*field);
}
}
let checksum = compute_checksum(&payload);
{
let map = expect_object_mut(&mut payload, path)?;
map.insert(CHECKSUM_FIELD.to_string(), Value::String(checksum));
}
let content = serde_json::to_string(&payload)
.with_context(|| format!("序列化最终 payload 失败: {path:?}"))?;
fs::write(path, content).with_context(|| format!("写入文件失败: {path:?}"))?;
Ok(())
}
pub fn load_position_from_file(path: &Path, symbol: &str, check: bool) -> Result<Position> {
let raw = fs::read_to_string(path).with_context(|| format!("读取文件失败: {path:?}"))?;
let mut payload: Value =
serde_json::from_str(&raw).with_context(|| format!("解析 JSON 失败: {path:?}"))?;
let stored_checksum: Option<String>;
let had_legacy_md5: bool;
{
let map = expect_object_mut(&mut payload, path)?;
stored_checksum = map
.remove(CHECKSUM_FIELD)
.and_then(|v| v.as_str().map(|s| s.to_string()));
had_legacy_md5 = map.remove(LEGACY_MD5_FIELD).is_some();
}
if check {
match (stored_checksum.as_ref(), had_legacy_md5) {
(Some(expected), _) => {
let actual = compute_checksum(&payload);
if expected != &actual {
return Err(anyhow!(
"checksum 不匹配(文件可能被篡改): {path:?}\n expected = {expected}\n actual = {actual}"
));
}
}
(None, true) => {
}
(None, false) => {
}
}
}
{
let map = expect_object_mut(&mut payload, path)?;
map.insert("symbol".to_string(), Value::String(symbol.to_string()));
}
let mut pos: Position = serde_json::from_value(payload)
.with_context(|| format!("反序列化 Position 失败: {path:?}"))?;
pos.normalize_runtime_fields();
Ok(pos)
}
fn compute_checksum(value: &Value) -> String {
let canonical = serde_json::to_string(value).expect("Value 序列化不应失败");
let mut hasher = Sha256::new();
hasher.update(canonical.as_bytes());
hex::encode(hasher.finalize())
}
fn expect_object_mut<'v>(value: &'v mut Value, path: &Path) -> Result<&'v mut Map<String, Value>> {
value.as_object_mut().ok_or_else(|| {
anyhow!("期望文件根节点是 JSON Object(Position payload),但实际不是: {path:?}")
})
}
#[cfg(test)]
mod tests {
use super::*;
use czsc_core::objects::position::{Position, load_position};
use serde_json::json;
use std::fs;
use tempfile::TempDir;
fn position_from_json(value: serde_json::Value) -> Position {
serde_json::from_value(value).expect("position payload must be valid")
}
fn minimal_position_payload(name: &str, signal_keys: &[&str]) -> serde_json::Value {
let all_signals: Vec<serde_json::Value> = signal_keys
.iter()
.map(|k| json!(format!("日线_{k}_kind_v1_v2_v3_0")))
.collect();
json!({
"symbol": "TEST",
"name": name,
"opens": [{
"operate": "开多",
"signals_all": all_signals,
"signals_any": [],
"signals_not": [],
"name": "open"
}],
"exits": [{
"operate": "平多",
"signals_all": [],
"signals_any": [],
"signals_not": [],
"name": "exit"
}],
"interval": 0,
"timeout": 0,
"stop_loss": 0.0,
"T0": false
})
}
#[test]
fn unique_signals_dedups_across_positions_in_order() {
let p1 = position_from_json(minimal_position_payload("p1", &["siga", "sigb"]));
let p2 = position_from_json(minimal_position_payload("p2", &["sigb", "sigc"]));
let result = unique_signals_across(&[p1, p2]);
assert_eq!(result.len(), 3, "got: {result:?}");
let set: HashSet<&str> = result.iter().map(|s| s.as_str()).collect();
assert!(set.iter().any(|s| s.contains("siga")));
assert!(set.iter().any(|s| s.contains("sigb")));
assert!(set.iter().any(|s| s.contains("sigc")));
}
#[test]
fn json_strategy_loads_files_and_binds_symbol() {
let dir = TempDir::new().unwrap();
let p1_path = dir.path().join("p1.json");
let p2_path = dir.path().join("p2.json");
let pos1 = position_from_json(minimal_position_payload("p1", &["siga"]));
let pos2 = position_from_json(minimal_position_payload("p2", &["siga", "sigb"]));
save_position_to_file(&pos1, &p1_path).unwrap();
save_position_to_file(&pos2, &p2_path).unwrap();
let strategy = JsonStrategy::from_files("MY_SYMBOL", [&p1_path, &p2_path], true).unwrap();
assert_eq!(strategy.symbol, "MY_SYMBOL");
assert_eq!(strategy.positions.len(), 2);
for pos in &strategy.positions {
assert_eq!(pos.symbol, "MY_SYMBOL");
}
let signals = strategy.unique_signals();
assert_eq!(signals.len(), 2);
}
#[test]
fn empty_strategy_yields_empty_signals() {
let strategy = JsonStrategy {
symbol: "X".to_string(),
positions: Vec::new(),
};
assert!(strategy.unique_signals().is_empty());
}
#[test]
fn save_writes_checksum_and_strips_symbol() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pos.json");
let pos = position_from_json(minimal_position_payload("p", &["siga"]));
save_position_to_file(&pos, &path).unwrap();
let raw = fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
let obj = value.as_object().unwrap();
assert!(obj.contains_key("checksum"), "新格式必须写出 checksum 字段");
assert!(
!obj.contains_key("symbol"),
"save 必须剥离 symbol,让配置可复用"
);
assert!(
!obj.contains_key("md5"),
"新写出的文件不应再有遗留的 md5 字段"
);
let checksum = obj["checksum"].as_str().unwrap();
assert_eq!(checksum.len(), 64, "SHA256 hex 字符串应为 64 位");
}
#[test]
fn round_trip_preserves_position() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pos.json");
let pos = position_from_json(minimal_position_payload("rt", &["siga", "sigb"]));
save_position_to_file(&pos, &path).unwrap();
let loaded = load_position_from_file(&path, "NEW_SYMBOL", true).unwrap();
assert_eq!(loaded.symbol, "NEW_SYMBOL");
assert_eq!(loaded.name, "rt");
assert_eq!(loaded.opens.len(), 1);
}
#[test]
fn tampered_file_fails_checksum_check() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("pos.json");
let pos = position_from_json(minimal_position_payload("t", &["siga"]));
save_position_to_file(&pos, &path).unwrap();
let raw = fs::read_to_string(&path).unwrap();
let tampered = raw.replace("\"name\":\"t\"", "\"name\":\"tampered\"");
fs::write(&path, tampered).unwrap();
let err = load_position_from_file(&path, "X", true).unwrap_err();
assert!(
err.to_string().contains("checksum 不匹配"),
"篡改后的 checksum 必须不匹配,实际错误:{err}"
);
let pos = load_position_from_file(&path, "X", false).unwrap();
assert_eq!(pos.name, "tampered");
}
#[test]
fn legacy_md5_file_loads_silently() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("legacy.json");
let mut payload = minimal_position_payload("legacy", &["siga"]);
payload.as_object_mut().unwrap().remove("symbol");
payload
.as_object_mut()
.unwrap()
.insert("md5".to_string(), Value::String("deadbeef".into()));
fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
let pos = load_position_from_file(&path, "X", true).unwrap();
assert_eq!(pos.name, "legacy");
assert_eq!(pos.symbol, "X");
}
#[test]
fn missing_checksum_with_check_true_passes() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("plain.json");
let mut payload = minimal_position_payload("plain", &["siga"]);
payload.as_object_mut().unwrap().remove("symbol");
fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
let pos = load_position_from_file(&path, "X", true).unwrap();
assert_eq!(pos.name, "plain");
}
#[test]
fn canonical_form_is_key_sorted() {
let v1 = json!({"a": 1, "b": 2, "c": {"x": 10, "y": 20}});
let v2 = json!({"c": {"y": 20, "x": 10}, "b": 2, "a": 1});
assert_eq!(compute_checksum(&v1), compute_checksum(&v2));
}
#[test]
fn load_position_core_helper_still_works() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("core.json");
let payload = minimal_position_payload("core", &["siga"]);
fs::write(&path, serde_json::to_string(&payload).unwrap()).unwrap();
let pos = load_position(&path).unwrap();
assert_eq!(pos.name, "core");
assert_eq!(pos.symbol, "TEST");
}
}