feat(cli,ingest): binding module + generalized column openers (#231 tasks 1-2)
crates/aura-cli/src/binding.rs: the closed column vocabulary (open/high/ low/close/spread/volume, price as the close alias) with ResolvedBinding — one ordered plan both halves of the single-price weld will share. resolve_binding applies override-wins-over-name-default, refuses unknown roles / bad override values / the price+close double-alias / a rebound reserved close role, synthesizes the broker-only close entry, and sorts into canonical M1Field order (the C4 merge tie-break contract). 12 exact-prose unit tests. Module-wide dead_code allow is scaffolding until the wrap seam consumes it next. aura-ingest: open_columns / open_columns_window open any field set in the given order (None if any field lacks an overlapping archive file); open_ohlc now delegates with the fixed OHLC order — behaviour-identical, still guarded by the open_ohlc_seam bit-identity test. Post-review polish: RESERVED_CLOSE_ROLE constant couples the synthesized close's two sites; dead_code note reworded condition-based. Verified: binding 12/12, ingest absence test green, full workspace suite green, clippy -D warnings clean; independent quality review approved. refs #231
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
//! Harness input binding — the one place strategy input-role NAMES meet archive
|
||||
//! COLUMNS (docs/specs/harness-input-binding.md, #231). A root input role whose
|
||||
//! name is in the closed column vocabulary (`open`/`high`/`low`/`close`/
|
||||
//! `spread`/`volume`, plus `price` as the backward-compatible alias of `close`)
|
||||
//! binds to that column of the run's instrument by default; a campaign
|
||||
//! document's `data.bindings` block (role -> column) overrides the name default
|
||||
//! per campaign. The resolved plan (`ResolvedBinding`) is consumed twice — to
|
||||
//! OPEN the columns (`aura_ingest::open_columns`) and to DECLARE the wrapped
|
||||
//! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order,
|
||||
//! so the two halves of the old single-price weld can no longer drift apart
|
||||
//! (C4 merge tie-break = role declaration order = source open order).
|
||||
//!
|
||||
//! `dead_code` is allowed module-wide only while nothing outside this
|
||||
//! module consumes it; the allow is scaffolding to REMOVE with the first
|
||||
//! consumer (the wrap seam), at which point genuinely unused items must
|
||||
//! surface again.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_core::ScalarKind;
|
||||
use aura_engine::Role;
|
||||
use aura_ingest::M1Field;
|
||||
|
||||
/// The reserved name of the synthesized broker/executor close entry —
|
||||
/// `finish` creates it under this name and `resolve_binding`'s collision
|
||||
/// guard defends it; one constant couples the two sites.
|
||||
const RESERVED_CLOSE_ROLE: &str = "close";
|
||||
|
||||
/// The vocabulary line every binding refusal names (single source for prose).
|
||||
pub(crate) const COLUMN_VOCABULARY: &str =
|
||||
"open, high, low, close (alias: price), spread, volume";
|
||||
|
||||
/// The closed role-name -> archive-column map (`price` aliases `close`).
|
||||
pub(crate) fn column_for_role(name: &str) -> Option<M1Field> {
|
||||
match name {
|
||||
"open" => Some(M1Field::Open),
|
||||
"high" => Some(M1Field::High),
|
||||
"low" => Some(M1Field::Low),
|
||||
"close" | "price" => Some(M1Field::Close),
|
||||
"spread" => Some(M1Field::Spread),
|
||||
"volume" => Some(M1Field::Volume),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical column name (refusal prose; the alias-free inverse of
|
||||
/// [`column_for_role`]).
|
||||
pub(crate) fn column_name(column: M1Field) -> &'static str {
|
||||
match column {
|
||||
M1Field::Open => "open",
|
||||
M1Field::High => "high",
|
||||
M1Field::Low => "low",
|
||||
M1Field::Close => "close",
|
||||
M1Field::Spread => "spread",
|
||||
M1Field::Volume => "volume",
|
||||
}
|
||||
}
|
||||
|
||||
/// The scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is
|
||||
/// the one `i64` column; everything else is `f64`).
|
||||
pub(crate) fn column_kind(column: M1Field) -> ScalarKind {
|
||||
match column {
|
||||
M1Field::Volume => ScalarKind::I64,
|
||||
_ => ScalarKind::F64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical rank = `M1Field` declaration order — the C4 merge tie-break
|
||||
/// order `open_ohlc` (#92) fixed and `open_columns` opens in.
|
||||
fn rank(column: M1Field) -> usize {
|
||||
match column {
|
||||
M1Field::Open => 0,
|
||||
M1Field::High => 1,
|
||||
M1Field::Low => 2,
|
||||
M1Field::Close => 3,
|
||||
M1Field::Spread => 4,
|
||||
M1Field::Volume => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// One resolved root role: the role name to declare (= the signal's port
|
||||
/// name), the archive column it binds, and whether it feeds the signal
|
||||
/// (`false` only for the synthesized broker/executor-only close entry).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BindingEntry {
|
||||
pub role: String,
|
||||
pub column: M1Field,
|
||||
pub feeds_signal: bool,
|
||||
}
|
||||
|
||||
/// The ordered binding plan `wrap_r` (role declaration) and the real-data
|
||||
/// openers (column opening) share: entries in canonical column order, with a
|
||||
/// close entry guaranteed (the broker/executor price feed).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ResolvedBinding {
|
||||
entries: Vec<BindingEntry>,
|
||||
}
|
||||
|
||||
impl ResolvedBinding {
|
||||
pub(crate) fn entries(&self) -> &[BindingEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The columns to open, one per entry, in canonical order — exactly the
|
||||
/// order the entries' roles are declared in, so source index i always
|
||||
/// feeds role i.
|
||||
pub(crate) fn columns(&self) -> Vec<M1Field> {
|
||||
self.entries.iter().map(|e| e.column).collect()
|
||||
}
|
||||
|
||||
/// True iff the strategy consumes the close column only — the one shape
|
||||
/// the synthetic walk (a single close series) can drive.
|
||||
pub(crate) fn close_only(&self) -> bool {
|
||||
self.columns() == [M1Field::Close]
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarantee a close entry (the broker/executor pair always feeds from
|
||||
/// close), synthesizing a signal-blind one when the strategy consumes none,
|
||||
/// then sort into canonical column order (stable: same-column entries keep
|
||||
/// their declared order). Shared tail of strict and probe resolution.
|
||||
fn finish(mut entries: Vec<BindingEntry>) -> ResolvedBinding {
|
||||
if !entries.iter().any(|e| e.column == M1Field::Close) {
|
||||
entries.push(BindingEntry {
|
||||
role: RESERVED_CLOSE_ROLE.to_string(),
|
||||
column: M1Field::Close,
|
||||
feeds_signal: false,
|
||||
});
|
||||
}
|
||||
entries.sort_by_key(|e| rank(e.column));
|
||||
ResolvedBinding { entries }
|
||||
}
|
||||
|
||||
/// Resolve a strategy's input roles against the campaign overrides: an
|
||||
/// override wins over the name default; a role that is neither
|
||||
/// vocabulary-named nor overridden is a refusal naming the vocabulary and the
|
||||
/// override remedy.
|
||||
pub(crate) fn resolve_binding(
|
||||
strategy: &str,
|
||||
roles: &[Role],
|
||||
overrides: &BTreeMap<String, String>,
|
||||
) -> Result<ResolvedBinding, String> {
|
||||
// `price` is the alias of `close`: both present is ambiguous (which one
|
||||
// is the broker feed?) — refuse rather than guess.
|
||||
if roles.iter().any(|r| r.name == "price") && roles.iter().any(|r| r.name == "close") {
|
||||
return Err(format!(
|
||||
"strategy \"{strategy}\" declares both \"price\" and \"close\" input roles — \
|
||||
\"price\" is the alias of \"close\", so the pair is ambiguous; rename one role"
|
||||
));
|
||||
}
|
||||
let mut entries: Vec<BindingEntry> = Vec::with_capacity(roles.len() + 1);
|
||||
for role in roles {
|
||||
let column = match overrides.get(&role.name) {
|
||||
Some(value) => column_for_role(value).ok_or_else(|| {
|
||||
format!(
|
||||
"binding for role \"{}\" of strategy \"{strategy}\" names no archive \
|
||||
column: \"{value}\" — bindable columns: {COLUMN_VOCABULARY}",
|
||||
role.name
|
||||
)
|
||||
})?,
|
||||
None => column_for_role(&role.name).ok_or_else(|| {
|
||||
format!(
|
||||
"input role \"{}\" of strategy \"{strategy}\" binds to no archive column \
|
||||
— bindable columns: {COLUMN_VOCABULARY}; or bind it explicitly in the \
|
||||
campaign data.bindings block",
|
||||
role.name
|
||||
)
|
||||
})?,
|
||||
};
|
||||
// A role literally named "close" bound away from the close column
|
||||
// would collide with `finish`'s synthesized close entry (two
|
||||
// entries named "close") — refuse rather than double-name.
|
||||
if role.name == RESERVED_CLOSE_ROLE && column != M1Field::Close {
|
||||
return Err(format!(
|
||||
"binding override renames role \"close\" to column \"{}\" — \"close\" is the \
|
||||
reserved name of the synthesized broker/executor entry; rename the role",
|
||||
column_name(column)
|
||||
));
|
||||
}
|
||||
entries.push(BindingEntry { role: role.name.clone(), column, feeds_signal: true });
|
||||
}
|
||||
Ok(finish(entries))
|
||||
}
|
||||
|
||||
/// The lenient PROBE binding for the throwaway param-space wrap
|
||||
/// (`blueprint_axis_probe`): every unresolvable role falls back to the close
|
||||
/// column. The probe composite is built but never run over data — the
|
||||
/// fallback is a placeholder exactly like the probe's synthetic pip, so
|
||||
/// probing never refuses a blueprint the (strictly resolved) run path may
|
||||
/// still bind via campaign overrides.
|
||||
pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
let entries: Vec<BindingEntry> = roles
|
||||
.iter()
|
||||
.map(|role| BindingEntry {
|
||||
role: role.name.clone(),
|
||||
column: column_for_role(&role.name).unwrap_or(M1Field::Close),
|
||||
feeds_signal: true,
|
||||
})
|
||||
.collect();
|
||||
finish(entries)
|
||||
}
|
||||
|
||||
/// The refusal for a multi-column binding on synthetic data (which generates
|
||||
/// a close series only). Callers invoke it only when `!binding.close_only()`.
|
||||
pub(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
|
||||
let beyond: Vec<&str> = binding
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|e| e.column != M1Field::Close)
|
||||
.map(|e| column_name(e.column))
|
||||
.collect();
|
||||
format!(
|
||||
"strategy \"{strategy}\" consumes columns beyond close ({}) — synthetic data \
|
||||
generates a close series only; run with --real <SYMBOL>",
|
||||
beyond.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn role(name: &str) -> Role {
|
||||
Role { name: name.to_string(), targets: vec![], source: Some(ScalarKind::F64) }
|
||||
}
|
||||
|
||||
/// The closed name map: six columns plus the `price` alias of close.
|
||||
#[test]
|
||||
fn column_for_role_maps_the_vocabulary_and_the_price_alias() {
|
||||
assert_eq!(column_for_role("open"), Some(M1Field::Open));
|
||||
assert_eq!(column_for_role("high"), Some(M1Field::High));
|
||||
assert_eq!(column_for_role("low"), Some(M1Field::Low));
|
||||
assert_eq!(column_for_role("close"), Some(M1Field::Close));
|
||||
assert_eq!(column_for_role("price"), Some(M1Field::Close));
|
||||
assert_eq!(column_for_role("spread"), Some(M1Field::Spread));
|
||||
assert_eq!(column_for_role("volume"), Some(M1Field::Volume));
|
||||
assert_eq!(column_for_role("sentiment"), None);
|
||||
}
|
||||
|
||||
/// Volume is the one i64 column (mirrors `aura_ingest::decode`).
|
||||
#[test]
|
||||
fn column_kind_is_i64_for_volume_and_f64_otherwise() {
|
||||
assert_eq!(column_kind(M1Field::Volume), ScalarKind::I64);
|
||||
assert_eq!(column_kind(M1Field::Close), ScalarKind::F64);
|
||||
assert_eq!(column_kind(M1Field::Open), ScalarKind::F64);
|
||||
}
|
||||
|
||||
/// The compatibility anchor: a single `price` role resolves to exactly
|
||||
/// today's weld — one close-bound entry, no synthesized extra.
|
||||
#[test]
|
||||
fn single_price_role_resolves_to_exactly_todays_weld() {
|
||||
let b = resolve_binding("s", &[role("price")], &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(b.entries().len(), 1);
|
||||
assert_eq!(b.entries()[0].role, "price");
|
||||
assert_eq!(b.entries()[0].column, M1Field::Close);
|
||||
assert!(b.entries()[0].feeds_signal);
|
||||
assert!(b.close_only());
|
||||
}
|
||||
|
||||
/// An override wins over the name default; rebinding `price` away from
|
||||
/// close synthesizes the broker-only close entry.
|
||||
#[test]
|
||||
fn override_wins_and_synthesizes_the_broker_close_entry() {
|
||||
let overrides = BTreeMap::from([("price".to_string(), "open".to_string())]);
|
||||
let b = resolve_binding("s", &[role("price")], &overrides).expect("resolves");
|
||||
assert_eq!(b.columns(), vec![M1Field::Open, M1Field::Close]);
|
||||
assert_eq!(b.entries()[0].role, "price");
|
||||
assert!(b.entries()[0].feeds_signal);
|
||||
assert_eq!(b.entries()[1].role, "close");
|
||||
assert!(!b.entries()[1].feeds_signal, "synthesized close feeds broker/executor only");
|
||||
assert!(!b.close_only());
|
||||
}
|
||||
|
||||
/// A high/low/close strategy shares its close entry with the broker —
|
||||
/// nothing synthesized.
|
||||
#[test]
|
||||
fn consumed_close_is_shared_not_duplicated() {
|
||||
let roles = [role("high"), role("low"), role("close")];
|
||||
let b = resolve_binding("s", &roles, &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(b.columns(), vec![M1Field::High, M1Field::Low, M1Field::Close]);
|
||||
assert!(b.entries().iter().all(|e| e.feeds_signal));
|
||||
}
|
||||
|
||||
/// Canonical ordering: declaration order in the blueprint is irrelevant —
|
||||
/// entries sort into `M1Field` declaration order (the C4 tie-break order).
|
||||
#[test]
|
||||
fn entries_sort_into_canonical_column_order() {
|
||||
let roles = [role("volume"), role("close"), role("open")];
|
||||
let b = resolve_binding("s", &roles, &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(b.columns(), vec![M1Field::Open, M1Field::Close, M1Field::Volume]);
|
||||
}
|
||||
|
||||
/// The unknown-role refusal names the vocabulary AND the override remedy
|
||||
/// (exact prose — the spec's error contract).
|
||||
#[test]
|
||||
fn unknown_role_refuses_naming_vocabulary_and_remedy() {
|
||||
let err = resolve_binding("x", &[role("sentiment")], &BTreeMap::new()).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"input role \"sentiment\" of strategy \"x\" binds to no archive column — \
|
||||
bindable columns: open, high, low, close (alias: price), spread, volume; \
|
||||
or bind it explicitly in the campaign data.bindings block"
|
||||
);
|
||||
}
|
||||
|
||||
/// An override VALUE outside the vocabulary refuses naming the vocabulary.
|
||||
#[test]
|
||||
fn bad_override_value_refuses_naming_the_vocabulary() {
|
||||
let overrides = BTreeMap::from([("price".to_string(), "hl2".to_string())]);
|
||||
let err = resolve_binding("x", &[role("price")], &overrides).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"binding for role \"price\" of strategy \"x\" names no archive column: \"hl2\" \
|
||||
— bindable columns: open, high, low, close (alias: price), spread, volume"
|
||||
);
|
||||
}
|
||||
|
||||
/// `price` + `close` both declared is the alias ambiguity — refused.
|
||||
#[test]
|
||||
fn price_and_close_together_refuse_as_ambiguous() {
|
||||
let err =
|
||||
resolve_binding("x", &[role("price"), role("close")], &BTreeMap::new()).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"strategy \"x\" declares both \"price\" and \"close\" input roles — \"price\" \
|
||||
is the alias of \"close\", so the pair is ambiguous; rename one role"
|
||||
);
|
||||
}
|
||||
|
||||
/// A role literally named "close" overridden away from the close column
|
||||
/// would otherwise collide with `finish`'s synthesized close entry (two
|
||||
/// entries both named "close") — refused instead of double-naming.
|
||||
#[test]
|
||||
fn role_named_close_overridden_away_refuses_the_reserved_name() {
|
||||
let overrides = BTreeMap::from([("close".to_string(), "open".to_string())]);
|
||||
let err = resolve_binding("x", &[role("close")], &overrides).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"binding override renames role \"close\" to column \"open\" — \"close\" is the \
|
||||
reserved name of the synthesized broker/executor entry; rename the role"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lenient probe binding never refuses: unresolvable roles fall back
|
||||
/// to close (probe-only, like the probe's synthetic pip).
|
||||
#[test]
|
||||
fn probe_binding_falls_back_to_close_for_unknown_roles() {
|
||||
let b = probe_binding(&[role("sentiment"), role("high")]);
|
||||
assert_eq!(b.columns(), vec![M1Field::High, M1Field::Close]);
|
||||
assert_eq!(b.entries()[1].role, "sentiment");
|
||||
assert!(b.entries()[1].feeds_signal);
|
||||
}
|
||||
|
||||
/// The synthetic refusal prose names the beyond-close columns + remedy.
|
||||
#[test]
|
||||
fn synthetic_refusal_names_columns_and_remedy() {
|
||||
let roles = [role("high"), role("low"), role("close")];
|
||||
let b = resolve_binding("hl_channel", &roles, &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(
|
||||
synthetic_refusal("hl_channel", &b),
|
||||
"strategy \"hl_channel\" consumes columns beyond close (high, low) — synthetic \
|
||||
data generates a close series only; run with --real <SYMBOL>"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! (or the `--strategy r-sma` sugar) and print canonical JSON metrics/manifests;
|
||||
//! this binary authors no built-in harness.
|
||||
|
||||
mod binding;
|
||||
mod render;
|
||||
mod graph_construct;
|
||||
mod project;
|
||||
|
||||
@@ -312,6 +312,53 @@ impl aura_engine::Source for M1FieldSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one real [`M1FieldSource`] per requested field for `symbol` over
|
||||
/// `[from_ms, to_ms]` (inclusive Unix-ms, `None` = open-ended), in the GIVEN
|
||||
/// field order — callers pass the canonical `M1Field` declaration order (the
|
||||
/// C4 merge tie-break order, #92), so source index i is field i. All sources
|
||||
/// share the one `Arc<DataServer>` (one `FileCache`), so a window's bars are
|
||||
/// parsed once and reused across the field decodes (C12).
|
||||
///
|
||||
/// Returns `None` if any field has no archived file overlapping the window
|
||||
/// (the [`open_ohlc`] contract — file-level absence; since every archive file
|
||||
/// carries all fields, one absent field means all are). A window that
|
||||
/// overlaps a file but holds zero matching bars yields sources whose first
|
||||
/// `peek` is `None`, not a `None` here.
|
||||
pub fn open_columns(
|
||||
server: &Arc<DataServer>,
|
||||
symbol: &str,
|
||||
from_ms: Option<i64>,
|
||||
to_ms: Option<i64>,
|
||||
fields: &[M1Field],
|
||||
) -> Option<Vec<Box<dyn aura_engine::Source>>> {
|
||||
fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
M1FieldSource::open(server, symbol, from_ms, to_ms, *f)
|
||||
.map(|s| Box::new(s) as Box<dyn aura_engine::Source>)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`open_columns`] over an epoch-ns [`Timestamp`] window — the engine-side
|
||||
/// mirror, exactly as [`M1FieldSource::open_window`] mirrors
|
||||
/// [`M1FieldSource::open`]; the ns→ms crossing happens once, here.
|
||||
pub fn open_columns_window(
|
||||
server: &Arc<DataServer>,
|
||||
symbol: &str,
|
||||
from: Option<Timestamp>,
|
||||
to: Option<Timestamp>,
|
||||
fields: &[M1Field],
|
||||
) -> Option<Vec<Box<dyn aura_engine::Source>>> {
|
||||
open_columns(
|
||||
server,
|
||||
symbol,
|
||||
from.map(epoch_ns_to_unix_ms),
|
||||
to.map(epoch_ns_to_unix_ms),
|
||||
fields,
|
||||
)
|
||||
}
|
||||
|
||||
/// Open the four real OHLC [`M1FieldSource`]s for `symbol` over the closed
|
||||
/// epoch-ns window `[from, to]`, in the FIXED order open, high, low, close — the
|
||||
/// C4 merge tie-break order a resampler's `Barrier(0)` group depends on (#92).
|
||||
@@ -330,11 +377,13 @@ pub fn open_ohlc(
|
||||
from: Timestamp,
|
||||
to: Timestamp,
|
||||
) -> Option<Vec<Box<dyn aura_engine::Source>>> {
|
||||
let open = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::Open)?;
|
||||
let high = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::High)?;
|
||||
let low = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::Low)?;
|
||||
let close = M1FieldSource::open_window(server, symbol, Some(from), Some(to), M1Field::Close)?;
|
||||
Some(vec![Box::new(open), Box::new(high), Box::new(low), Box::new(close)])
|
||||
open_columns_window(
|
||||
server,
|
||||
symbol,
|
||||
Some(from),
|
||||
Some(to),
|
||||
&[M1Field::Open, M1Field::High, M1Field::Low, M1Field::Close],
|
||||
)
|
||||
}
|
||||
|
||||
/// Construct the default data-server over the local archive at
|
||||
@@ -398,6 +447,24 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_columns_propagates_file_level_absence_as_none() {
|
||||
// The open_ohlc contract, generalized: file-level absence (unknown
|
||||
// symbol / no overlapping archive file) is None for the whole set —
|
||||
// hermetic (a bogus path has no archive whether or not local data
|
||||
// exists), like instrument_geometry_none_for_unknown_symbol above.
|
||||
let server = Arc::new(DataServer::new("/nonexistent-archive-path"));
|
||||
assert!(open_columns(&server, "NOSYM", None, None, &[M1Field::Close]).is_none());
|
||||
assert!(open_columns_window(
|
||||
&server,
|
||||
"NOSYM",
|
||||
Some(Timestamp(0)),
|
||||
Some(Timestamp(1)),
|
||||
&[M1Field::Open, M1Field::Close]
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// A hand-built M1 bar with distinct per-field values so a transpose test
|
||||
/// can tell the columns apart.
|
||||
fn full_bar(time_ms: i64) -> M1Parsed {
|
||||
|
||||
Reference in New Issue
Block a user