1use crate::beautify::DEFAULT_TABLE_MAX_COLUMNS;
7use crate::error::Result;
8use crate::field::{
9 all_countries, all_exchanges, all_index_symbols, all_industries, all_markets, all_ratings,
10 all_sectors, get_preset, list_presets, resolve_field, search_fields, to_symbolset_wire, Asset,
11 NamedValue,
12};
13use crate::util::format_value;
14use crate::ScreenerRow;
15use std::collections::BTreeSet;
16use std::fmt::Write as _;
17
18fn format_const_wire_list(title: &str, items: &[NamedValue], footer: Option<&str>) -> String {
19 let mut out = format!("{title}\n");
20 for item in items {
21 let _ = writeln!(out, " - **{}**: `{}`", item.const_name, item.value);
22 }
23 if let Some(footer) = footer {
24 out.push('\n');
25 out.push_str(footer);
26 if !footer.ends_with('\n') {
27 out.push('\n');
28 }
29 }
30 out
31}
32
33#[must_use]
35pub fn format_discover_fields(asset: Asset, search_term: &str, limit: usize) -> String {
36 let limit = limit.clamp(1, 100);
37 let hits = search_fields(asset, search_term);
38 let mut lines = Vec::new();
39 for def in hits.into_iter().take(limit) {
40 let Some((const_name, resolved)) = resolve_field(asset, &def.field_name) else {
41 continue;
42 };
43 let marker = if resolved.interval {
44 " [Technical]"
45 } else {
46 ""
47 };
48 lines.push(format!("- **{const_name}**: {}{marker}", resolved.label));
49 }
50 if lines.is_empty() {
51 format!("No fields found matching '{search_term}'")
52 } else {
53 format!(
54 "Found {} fields matching '{search_term}':\n\n{}\n",
55 lines.len(),
56 lines.join("\n")
57 )
58 }
59}
60
61#[must_use]
63pub fn format_field_types(asset: Asset) -> String {
64 const KEYWORDS: &[&str] = &[
65 "price",
66 "volume",
67 "moving_average",
68 "rsi",
69 "macd",
70 "bollinger",
71 "earnings",
72 "dividend",
73 "market_cap",
74 "sector",
75 "recommend",
76 ];
77 let mut out = format!("Field categories for {}:\n\n", asset.as_str());
78 for keyword in KEYWORDS {
79 let names: Vec<String> = search_fields(asset, keyword)
80 .into_iter()
81 .take(5)
82 .filter_map(|def| resolve_field(asset, &def.field_name).map(|(n, _)| n))
83 .collect();
84 if names.is_empty() {
85 continue;
86 }
87 let _ = writeln!(out, "**{keyword}**:");
88 for name in names {
89 let _ = writeln!(out, " - {name}");
90 }
91 out.push('\n');
92 }
93 out.push_str("Use discover_fields('keyword') to find more fields in each category.\n");
94 out
95}
96
97#[must_use]
99pub fn format_list_presets() -> String {
100 let names = list_presets();
101 let mut out = String::from("Available presets:\n");
102 for name in names {
103 let _ = writeln!(out, " - {name}");
104 }
105 out
106}
107
108#[must_use]
110pub fn format_list_sectors() -> String {
111 format_const_wire_list(
112 "Available sectors (const → wire):",
113 all_sectors(),
114 Some(
115 "Pass const names (e.g. `TECHNOLOGY_SERVICES`) or wire values to `search_stocks` `sectors` CSV.\n",
116 ),
117 )
118}
119
120#[must_use]
122pub fn format_list_countries() -> String {
123 format_const_wire_list("Available countries (const → wire):", all_countries(), None)
124}
125
126#[must_use]
128pub fn format_list_industries() -> String {
129 format_const_wire_list(
130 "Available industries (const → wire):",
131 all_industries(),
132 None,
133 )
134}
135
136#[must_use]
138pub fn format_list_exchanges() -> String {
139 format_const_wire_list("Available exchanges (const → wire):", all_exchanges(), None)
140}
141
142#[must_use]
144pub fn format_list_ratings() -> String {
145 let mut out = String::from("Available recommendation ratings (const → [min, max] → label):\n");
146 for r in all_ratings() {
147 let range = r
148 .range()
149 .map_or_else(|| "n/a".into(), |[lo, hi]| format!("[{lo}, {hi}]"));
150 let _ = writeln!(out, " - **{}**: {} - {}", r.const_name, range, r.label);
151 }
152 out
153}
154
155#[must_use]
157pub fn format_list_filter_operators() -> String {
158 const OPS: &[(&str, &str)] = &[
159 (">=", "Greater than or equal"),
160 (">", "Greater than"),
161 ("<=", "Less than or equal"),
162 ("<", "Less than"),
163 ("==", "Equal to"),
164 ("!=", "Not equal to"),
165 ("match", "Text contains (for text fields like SECTOR)"),
166 (
167 "in_range",
168 "Value between [min, max] (e.g. RSI between 30-70)",
169 ),
170 ("not_in_range", "Value outside [min, max]"),
171 ("crosses", "Value crosses another"),
172 ("crosses_up", "Value crosses above another"),
173 ("crosses_down", "Value crosses below another"),
174 ];
175 let mut out = String::from("Available filter operators for custom_query:\n\n");
176 for (op, desc) in OPS {
177 let _ = writeln!(out, "- **{op}**: {desc}");
178 }
179 out.push_str(
180 "\nExample filters JSON:\n\
181 ```json\n\
182 [\n\
183 {\"field\": \"PRICE\", \"op\": \">=\", \"value\": 100},\n\
184 {\"field\": \"RSI\", \"op\": \"in_range\", \"value\": [30, 70]},\n\
185 {\"field\": \"SECTOR\", \"op\": \"match\", \"value\": \"Technology Services\"}\n\
186 ]\n\
187 ```\n",
188 );
189 out
190}
191
192#[must_use]
194pub fn format_list_markets() -> String {
195 let mut out = String::from("Available markets (const → wire):\n");
196 for m in all_markets() {
197 let _ = writeln!(out, " - **{}**: `{}`", m.const_name, m.value);
198 }
199 out.push_str(
200 "\nPass const names or wire values to `build_payload` / `search_by_index` via `markets` CSV.\n",
201 );
202 out
203}
204
205#[must_use]
207pub fn format_list_index_symbols() -> String {
208 let mut out = String::from("Available index symbols (const → catalog → payload symbolset):\n");
209 for idx in all_index_symbols() {
210 let wire = to_symbolset_wire(&idx.value);
211 let _ = writeln!(
212 out,
213 " - **{}**: `{}` → `{}` ({})",
214 idx.const_name, idx.value, wire, idx.label
215 );
216 }
217 out.push_str(
218 "\nPass const names (e.g. `SP500`) or catalog values (e.g. `SP;SPX`) to `indices` CSV.\n",
219 );
220 out
221}
222
223pub fn format_get_preset(name: &str) -> Result<String> {
229 let fields = get_preset(name)?;
230 let mut out = format!("Preset `{name}` ({} fields):\n\n", fields.len());
231 for field in fields {
232 let _ = writeln!(out, "- {} (`{}`)", field.label, field.field_name);
233 }
234 Ok(out)
235}
236
237#[must_use]
239pub fn format_rows_markdown(rows: &[ScreenerRow], max_rows: usize) -> String {
240 if rows.is_empty() {
241 return "No results found matching the criteria.".into();
242 }
243 let rows: Vec<&ScreenerRow> = rows.iter().take(max_rows).collect();
244 let mut labels: Vec<String> = rows
245 .iter()
246 .flat_map(|r| r.data.keys().cloned())
247 .collect::<BTreeSet<_>>()
248 .into_iter()
249 .collect();
250 if labels.len() > DEFAULT_TABLE_MAX_COLUMNS {
252 labels.truncate(DEFAULT_TABLE_MAX_COLUMNS);
253 }
254
255 let mut header = String::from("| Symbol |");
256 let mut sep = String::from("| --- |");
257 for label in &labels {
258 let _ = write!(header, " {label} |");
259 sep.push_str(" --- |");
260 }
261 header.push('\n');
262 sep.push('\n');
263
264 let mut body = String::new();
265 for row in rows {
266 let _ = write!(body, "| {} |", row.symbol);
267 for label in &labels {
268 let cell = row
269 .data
270 .get(label)
271 .map_or_else(|| "--".into(), format_value);
272 let _ = write!(body, " {cell} |");
273 }
274 body.push('\n');
275 }
276 format!("{header}{sep}{body}")
277}