diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 5806c25..5a75672 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -1,8 +1,8 @@ + android:versionCode="5" + android:versionName="0.4.1"> diff --git a/src/com/dsh/openterm/MainActivity.java b/src/com/dsh/openterm/MainActivity.java index fb595ef..9739e7a 100644 --- a/src/com/dsh/openterm/MainActivity.java +++ b/src/com/dsh/openterm/MainActivity.java @@ -19,8 +19,10 @@ import android.view.ViewGroup; import android.view.inputmethod.EditorInfo; import android.widget.BaseAdapter; import android.widget.EditText; +import android.widget.GridLayout; import android.widget.LinearLayout; import android.widget.ListView; +import android.widget.ScrollView; import android.widget.TextView; import android.widget.Toast; @@ -51,23 +53,58 @@ public class MainActivity extends Activity implements View.OnClickListener { /* ══════════ 数据模型 ══════════ */ static class Quote { - String sym; // 显示符号 AAPL / BMW.DE / BTC-USD - String yahoo; // Yahoo 代码 - String tencent; // 腾讯代码(国内回落,可空) - String name = ""; + String sym, yahoo, tencent, name = ""; + String emSecid = ""; // 东方财富代码(国内直连) String price = "--", change = "--", pct = "--"; double priceNum = Double.NaN, prevNum = Double.NaN; - String highS = "--", lowS = "--", volS = "--"; + String highS = "--", lowS = "--", volS = "--", hi52S = "--", lo52S = "--"; String currency = "", exchange = ""; boolean up, loaded; Quote(String yahoo, String tencent, String name) { this.yahoo = yahoo; this.tencent = tencent; this.name = name; this.sym = yahoo; + this.emSecid = emSecidFor(yahoo); } } + /* ══════════ 东方财富代码映射(国内网络主力源) ══════════ */ + static String emSecidFor(String y) { + if (y == null) return ""; + if (y.equals("^GSPC")) return "100.SPX"; + if (y.equals("^IXIC")) return "100.NDX"; + if (y.equals("^DJI")) return "100.DJIA"; + if (y.equals("^HSI")) return "100.HSI"; + if (y.equals("^N225")) return "100.N225"; + if (y.equals("^FTSE")) return "100.FTSE"; + if (y.equals("^GDAXI")) return "100.GDAXI"; + if (y.equals("^STOXX50E")) return "100.STOXX50E"; + if (y.equals("EURUSD=X")) return "119.EURUSD"; + if (y.equals("GBPUSD=X")) return "119.GBPUSD"; + if (y.equals("USDJPY=X")) return "119.USDJPY"; + if (y.equals("USDCNH=X")) return "119.USDCNH"; + if (y.equals("^TNX")) return "119.US10Y"; + if (y.equals("DX-Y.NYB")) return "100.UDI"; + // 美股:无后缀大写代码 → 105(纳斯达克)/106(纽交所),运行时自动探测 + if (y.matches("[A-Z]{1,5}")) return "105." + y; + return ""; + } + + /* 新浪期货映射(大宗商品国内源) */ + static String sinaHfFor(String y) { + if (y == null) return ""; + if (y.equals("GC=F")) return "hf_GC"; + if (y.equals("SI=F")) return "hf_SI"; + if (y.equals("HG=F")) return "hf_CAD"; + if (y.equals("CL=F")) return "hf_CL"; + if (y.equals("BZ=F")) return "hf_OIL"; + if (y.equals("NG=F")) return "hf_NG"; + return ""; + } + static class MPoint { double t; double price; } + static class Candle { double t, o, h, l, c, v; } + static class NewsItem { String time = "", title = "", link = ""; } static class SearchHit { String symbol, name, exchange, type; } @@ -90,6 +127,25 @@ public class MainActivity extends Activity implements View.OnClickListener { {"EURUSD=X",null, "EUR/USD"}, }; + /* ══════════ MON 世界市场总览标的 ══════════ */ + static final String[][] MON = { + // 组名, 代码, 名称 + {"IDX", "^GSPC", "S&P 500"}, {"IDX", "^IXIC", "NASDAQ"}, + {"IDX", "^DJI", "DOW"}, {"IDX", "^HSI", "HANG SENG"}, + {"IDX", "^N225", "NIKKEI"}, {"IDX", "^FTSE", "FTSE 100"}, + {"IDX", "^GDAXI", "DAX"}, {"IDX", "^STOXX50E", "STOXX 50"}, + {"CMD", "CL=F", "WTI CRUDE"}, {"CMD", "BZ=F", "BRENT"}, + {"CMD", "GC=F", "GOLD"}, {"CMD", "SI=F", "SILVER"}, + {"CMD", "HG=F", "COPPER"}, {"CMD", "NG=F", "NAT GAS"}, + {"RATES", "^TNX", "US 10Y"}, {"RATES", "^TYX", "US 30Y"}, + {"RATES", "^FVX", "US 5Y"}, + {"FX", "DX-Y.NYB", "DOLLAR IDX"}, {"FX", "EURUSD=X", "EUR/USD"}, + {"FX", "USDJPY=X", "USD/JPY"}, {"FX", "GBPUSD=X", "GBP/USD"}, + {"FX", "USDCNH=X", "USD/CNH"}, + {"CRYPTO", "BTC-USD", "BITCOIN"}, {"CRYPTO", "ETH-USD", "ETHEREUM"}, + {"CRYPTO", "SOL-USD", "SOLANA"}, + }; + /* ══════════ 配色 ══════════ */ static final int BG = 0xFF0A0A0A, PANEL = 0xFF141414, BAR = 0xFF1B1B1B; static final int ORANGE = 0xFFFF9900, AMBER = 0xFFFFD580; @@ -97,25 +153,27 @@ public class MainActivity extends Activity implements View.OnClickListener { static final int WHITE = 0xFFE8E8E8, GREY = 0xFF808080, LINE = 0xFF2A2A2A; /* ══════════ 状态 ══════════ */ - static final int PAGE_WATCH = 0, PAGE_NEWS = 1, PAGE_SEARCH = 2, PAGE_DETAIL = 3; + static final int PAGE_WATCH = 0, PAGE_NEWS = 1, PAGE_SEARCH = 2, PAGE_DETAIL = 3, PAGE_MON = 4; int page = PAGE_WATCH; final List quotes = new ArrayList<>(); + final List monQuotes = new ArrayList<>(); final List news = new ArrayList<>(); final List detailNews = new ArrayList<>(); final List hits = new ArrayList<>(); Quote detail; + String detailRange = "1D"; QuoteAdapter quoteAdapter; NewsAdapter newsAdapter, detailNewsAdapter; HitAdapter hitAdapter; ListView quoteList, newsList, hitList, dNewsList; - LinearLayout pageWatch, pageNews, pageSearch, pageDetail; - TextView tvClock, tvStatus, tvStats, tvTicker; - TextView tabWatch, tabNews, tabSearch; - TextView dTitle, dName, dCode, dPrice, dChange, dO, dH, dL, dP, dV, dUpdated, dNewsHead; - EditText searchBox; - MinuteChart chart; - LinearLayout clocksBar; + LinearLayout pageWatch, pageNews, pageSearch, pageDetail, pageMon; + TextView tvClock, tvStatus, tvStats, tvTicker, tvMonStatus; + TextView tabWatch, tabNews, tabSearch, tabMon; + TextView dTitle, dName, dPrice, dChange, dH, dL, dP, dV, dUpdated, dNewsHead, dHi52, dLo52; + EditText searchBox, cmdBox; + LinearLayout monGrid, rangeBar; + CandleChart chart; final Handler ui = new Handler(Looper.getMainLooper()); final ExecutorService net = Executors.newSingleThreadExecutor(); @@ -126,9 +184,11 @@ public class MainActivity extends Activity implements View.OnClickListener { super.onCreate(b); prefs = getSharedPreferences("openterm", MODE_PRIVATE); loadWatchlist(); + loadMon(); buildUi(); refreshQuotes(); ui.postDelayed(quoteLoop, 30000); + ui.postDelayed(monLoop, 45000); ui.postDelayed(tick, 1000); ui.postDelayed(newsLoop, 1000); } @@ -143,6 +203,12 @@ public class MainActivity extends Activity implements View.OnClickListener { refreshQuotes(); ui.postDelayed(this, 30000); }}; + final Runnable monLoop = new Runnable() { @Override public void run() { + if (page == PAGE_MON || firstMon) refreshMon(); + ui.postDelayed(this, 45000); + }}; + boolean firstMon = true; + final Runnable newsLoop = new Runnable() { @Override public void run() { if (page == PAGE_NEWS || news.isEmpty()) fetchNews(); ui.postDelayed(this, 90000); @@ -181,6 +247,11 @@ public class MainActivity extends Activity implements View.OnClickListener { } catch (Exception ignored) { } } + void loadMon() { + monQuotes.clear(); + for (String[] m : MON) monQuotes.add(new Quote(m[1], null, m[2])); + } + /* ══════════ UI ══════════ */ void buildUi() { @@ -199,6 +270,31 @@ public class MainActivity extends Activity implements View.OnClickListener { bar.addView(tvClock, cp); root.addView(bar, matchWrap()); + /* ── CMD 命令栏(Bloomberg 风格) ── */ + LinearLayout cmdBar = new LinearLayout(this); + cmdBar.setOrientation(LinearLayout.HORIZONTAL); + cmdBar.setBackgroundColor(0xFF101010); + cmdBar.setPadding(dp(12), dp(2), dp(12), dp(2)); + cmdBar.addView(mono("CMD>", 12, ORANGE, Typeface.BOLD), + wrapWrap()); + cmdBox = new EditText(this); + cmdBox.setHint(t("代码 / 功能 (AAPL, WEQ, NWS, SRCH, MON)", + "SYMBOL / FUNC (AAPL, WEQ, NWS, SRCH, MON)")); + cmdBox.setTextColor(WHITE); cmdBox.setHintTextColor(GREY); + cmdBox.setTextSize(12); + cmdBox.setTypeface(Typeface.MONOSPACE); + cmdBox.setBackgroundColor(0xFF101010); + cmdBox.setSingleLine(); + cmdBox.setImeOptions(EditorInfo.IME_ACTION_GO); + cmdBox.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override public boolean onEditorAction(TextView v, int actionId, android.view.KeyEvent ev) { + if (actionId == EditorInfo.IME_ACTION_GO) { runCmd(v.getText().toString()); return true; } + return false; + } + }); + cmdBar.addView(cmdBox, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + root.addView(cmdBar, matchWrap()); + clocksBar = new LinearLayout(this); clocksBar.setOrientation(LinearLayout.HORIZONTAL); clocksBar.setBackgroundColor(PANEL); @@ -208,11 +304,14 @@ public class MainActivity extends Activity implements View.OnClickListener { LinearLayout nav = new LinearLayout(this); nav.setOrientation(LinearLayout.HORIZONTAL); nav.setBackgroundColor(PANEL); - nav.setPadding(dp(8), dp(4), dp(8), dp(0)); + nav.setPadding(dp(6), dp(4), dp(6), dp(0)); tabWatch = tabBtn("1 " + t("自选", "WATCH"), PAGE_WATCH); tabNews = tabBtn("2 " + t("快讯", "NEWS"), PAGE_NEWS); - tabSearch= tabBtn("3 " + t("搜索", "SEARCH"), PAGE_SEARCH); - nav.addView(tabWatch, navLP()); nav.addView(tabNews, navLP()); nav.addView(tabSearch, navLP()); + tabSearch= tabBtn("3 " + t("搜索", "SRCH"), PAGE_SEARCH); + tabMon = tabBtn("4 " + t("总览", "MON"), PAGE_MON); + LinearLayout.LayoutParams np = navLP(); + nav.addView(tabWatch, np); nav.addView(tabNews, np); + nav.addView(tabSearch, np); nav.addView(tabMon, np); root.addView(nav, matchWrap()); LinearLayout sub = new LinearLayout(this); @@ -228,12 +327,13 @@ public class MainActivity extends Activity implements View.OnClickListener { pageWatch = buildWatchPage(); pageNews = buildNewsPage(); pageSearch = buildSearchPage(); + pageMon = buildMonPage(); pageDetail = buildDetailPage(); - for (LinearLayout p : new LinearLayout[]{pageWatch, pageNews, pageSearch, pageDetail}) + for (LinearLayout p : new LinearLayout[]{pageWatch, pageNews, pageSearch, pageMon, pageDetail}) root.addView(p, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); - tvTicker = mono(" OPENTERM 0.3 INTL · " + tvTicker = mono(" OPENTERM 0.4 INTL · " + t("数据:Yahoo Finance 公开接口", "Data: Yahoo Finance public API") + " · " + t("仅供参考,不构成投资建议", "For information only, not investment advice") + " ", 12, AMBER, Typeface.NORMAL); @@ -245,6 +345,23 @@ public class MainActivity extends Activity implements View.OnClickListener { showPage(PAGE_WATCH); } + /* ── 命令解析 ── */ + void runCmd(String raw) { + String s = raw.trim().toUpperCase(Locale.US); + if (s.isEmpty()) return; + if (s.equals("WEQ") || s.equals("1")) showPage(PAGE_WATCH); + else if (s.equals("NWS") || s.equals("2")) showPage(PAGE_NEWS); + else if (s.equals("SRCH") || s.equals("3")) { showPage(PAGE_SEARCH); searchBox.requestFocus(); } + else if (s.equals("MON") || s.equals("4")) showPage(PAGE_MON); + else { + String sym = s.replace(" DES", "").replace(" EQUITY", "").trim(); + if (!sym.isEmpty()) openDetail(sym); + } + cmdBox.setText(""); + } + + LinearLayout clocksBar; + LinearLayout.LayoutParams matchWrap() { return new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); @@ -256,16 +373,16 @@ public class MainActivity extends Activity implements View.OnClickListener { LinearLayout.LayoutParams navLP() { LinearLayout.LayoutParams p = new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1); - p.setMargins(dp(4), 0, dp(4), 0); + p.setMargins(dp(3), 0, dp(3), 0); return p; } TextView tabBtn(String label, final int target) { TextView tv = new TextView(this); - tv.setText(label); tv.setTextSize(12); + tv.setText(label); tv.setTextSize(11); tv.setTypeface(Typeface.MONOSPACE, Typeface.BOLD); tv.setGravity(Gravity.CENTER); - tv.setPadding(dp(6), dp(6), dp(6), dp(6)); + tv.setPadding(dp(4), dp(6), dp(4), dp(6)); tv.setOnClickListener(this); tv.setTag(target); tv.setBackgroundColor(PANEL); @@ -352,6 +469,30 @@ public class MainActivity extends Activity implements View.OnClickListener { return p; } + /* ── MON 世界市场总览 ── */ + LinearLayout buildMonPage() { + LinearLayout p = new LinearLayout(this); + p.setOrientation(LinearLayout.VERTICAL); + p.setBackgroundColor(BG); + LinearLayout head = new LinearLayout(this); + head.setOrientation(LinearLayout.HORIZONTAL); + head.setBackgroundColor(0xFF1E1E1E); + head.setPadding(dp(12), dp(5), dp(12), dp(5)); + head.addView(mono("MON · " + t("世界市场总览", "WORLD MARKETS"), 12, ORANGE, Typeface.BOLD), rowLP(3f)); + tvMonStatus = mono("", 10, GREY, Typeface.NORMAL); + LinearLayout.LayoutParams hp = rightLP(2f); hp.gravity = Gravity.CENTER_VERTICAL; + head.addView(tvMonStatus, hp); + p.addView(head, matchWrap()); + ScrollView sc = new ScrollView(this); + sc.setBackgroundColor(BG); + monGrid = new LinearLayout(this); + monGrid.setOrientation(LinearLayout.VERTICAL); + sc.addView(monGrid); + p.addView(sc, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1)); + return p; + } + LinearLayout buildDetailPage() { LinearLayout p = new LinearLayout(this); p.setOrientation(LinearLayout.VERTICAL); @@ -376,19 +517,39 @@ public class MainActivity extends Activity implements View.OnClickListener { dChange.setPadding(dp(12), 0, dp(12), dp(4)); p.addView(dChange, matchWrap()); - chart = new MinuteChart(this); + /* 周期切换条 */ + rangeBar = new LinearLayout(this); + rangeBar.setOrientation(LinearLayout.HORIZONTAL); + rangeBar.setPadding(dp(12), dp(2), dp(12), dp(4)); + String[] rs = {"1D", "5D", "1M", "6M", "1Y"}; + for (final String r : rs) { + TextView b = mono(r, 11, detailRange.equals(r) ? ORANGE : GREY, Typeface.BOLD); + b.setPadding(dp(10), dp(3), dp(10), dp(3)); + b.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + detailRange = r; + renderRangeBar(); + fetchDetail(); + }}); + b.setTag("r" + r); + rangeBar.addView(b, wrapWrap()); + } + p.addView(rangeBar, matchWrap()); + + chart = new CandleChart(this); p.addView(chart, new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, dp(200))); + ViewGroup.LayoutParams.MATCH_PARENT, dp(240))); LinearLayout grid = new LinearLayout(this); grid.setOrientation(LinearLayout.VERTICAL); grid.setBackgroundColor(PANEL); - LinearLayout r1 = kvRow(), r2 = kvRow(), r3 = kvRow(); + LinearLayout r1 = kvRow(), r2 = kvRow(), r3 = kvRow(), r4 = kvRow(); dH = kvCell(r1, t("最高", "HIGH")); dL = kvCell(r1, t("最低", "LOW")); dP = kvCell(r2, t("昨收", "PREV CLOSE")); dV = kvCell(r2, t("成交量", "VOLUME")); - TextView exch = kvCell(r3, t("交易所", "EXCHANGE")); exch.setTag("exch"); - TextView cur = kvCell(r3, t("币种", "CURRENCY")); cur.setTag("cur"); - grid.addView(r1, matchWrap()); grid.addView(r2, matchWrap()); grid.addView(r3, matchWrap()); + dHi52 = kvCell(r3, t("52周高", "52W HIGH")); dLo52 = kvCell(r3, t("52周低", "52W LOW")); + kvCell(r4, t("交易所", "EXCHANGE")).setTag("exch"); + kvCell(r4, t("币种", "CURRENCY")).setTag("cur"); + grid.addView(r1, matchWrap()); grid.addView(r2, matchWrap()); + grid.addView(r3, matchWrap()); grid.addView(r4, matchWrap()); p.addView(grid, matchWrap()); dNewsHead = mono(t("相关新闻", "RELATED NEWS"), 11, ORANGE, Typeface.BOLD); @@ -417,6 +578,14 @@ public class MainActivity extends Activity implements View.OnClickListener { return p; } + void renderRangeBar() { + for (int i = 0; i < rangeBar.getChildCount(); i++) { + TextView b = (TextView) rangeBar.getChildAt(i); + String r = (String) b.getTag(); + b.setTextColor(r.equals("r" + detailRange) ? ORANGE : GREY); + } + } + LinearLayout kvRow() { LinearLayout r = new LinearLayout(this); r.setOrientation(LinearLayout.HORIZONTAL); @@ -457,13 +626,17 @@ public class MainActivity extends Activity implements View.OnClickListener { pageWatch.setVisibility(which == PAGE_WATCH ? View.VISIBLE : View.GONE); pageNews.setVisibility(which == PAGE_NEWS ? View.VISIBLE : View.GONE); pageSearch.setVisibility(which == PAGE_SEARCH ? View.VISIBLE : View.GONE); + pageMon.setVisibility(which == PAGE_MON ? View.VISIBLE : View.GONE); pageDetail.setVisibility(which == PAGE_DETAIL ? View.VISIBLE : View.GONE); - tabWatch.setBackgroundColor(which == PAGE_WATCH ? BAR : PANEL); - tabNews.setBackgroundColor(which == PAGE_NEWS ? BAR : PANEL); - tabSearch.setBackgroundColor(which == PAGE_SEARCH ? BAR : PANEL); + int on = 0xFF242424; + tabWatch.setBackgroundColor(which == PAGE_WATCH ? on : PANEL); + tabNews.setBackgroundColor(which == PAGE_NEWS ? on : PANEL); + tabSearch.setBackgroundColor(which == PAGE_SEARCH ? on : PANEL); + tabMon.setBackgroundColor(which == PAGE_MON ? on : PANEL); tabWatch.setTextColor(which == PAGE_WATCH ? ORANGE : GREY); tabNews.setTextColor(which == PAGE_NEWS ? ORANGE : GREY); tabSearch.setTextColor(which == PAGE_SEARCH ? ORANGE : GREY); + tabMon.setTextColor(which == PAGE_MON ? ORANGE : GREY); } @Override public void onClick(View v) { @@ -498,13 +671,16 @@ public class MainActivity extends Activity implements View.OnClickListener { /* ══════════ HTTP ══════════ */ - String httpGet(String url) throws Exception { + String httpGet(String url) throws Exception { return httpGet(url, null); } + + String httpGet(String url, String referer) throws Exception { URL u = new URL(url); HttpURLConnection c = (HttpURLConnection) u.openConnection(); - c.setConnectTimeout(10000); c.setReadTimeout(10000); + c.setConnectTimeout(8000); c.setReadTimeout(8000); c.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/120 Safari/537.36"); c.setRequestProperty("Accept", "*/*"); + if (referer != null) c.setRequestProperty("Referer", referer); BufferedReader r = new BufferedReader(new InputStreamReader(c.getInputStream(), "UTF-8")); StringBuilder sb = new StringBuilder(); String line; while ((line = r.readLine()) != null) sb.append(line); @@ -516,39 +692,50 @@ public class MainActivity extends Activity implements View.OnClickListener { void refreshQuotes() { setStatus(t("刷新中 …", "Refreshing …")); - net.submit(new Runnable() { @Override public void run() { fetchQuotes(); }}); + net.submit(new Runnable() { @Override public void run() { fetchList(quotes, false); }}); + } + + void refreshMon() { + tvMonStatus.setText(t("刷新中…", "loading…")); + net.submit(new Runnable() { @Override public void run() { fetchList(monQuotes, true); }}); } void setStatus(final String s) { ui.post(new Runnable() { @Override public void run() { tvStatus.setText(s); }}); } - void fetchQuotes() { + void fetchList(List list, final boolean mon) { int ok = 0; - for (Quote q : quotes) { + for (Quote q : list) { if (fetchYahoo(q)) ok++; - else if (fetchStooq(q)) ok++; + else if (fetchEastMoney(q)) ok++; else if (q.tencent != null && fetchTencent(q)) ok++; + else if (fetchSinaHf(q)) ok++; + else if (fetchStooq(q)) ok++; + try { Thread.sleep(120); } catch (InterruptedException e) { return; } } final int n = ok; ui.post(new Runnable() { @Override public void run() { - int up = 0, down = 0; - for (Quote q : quotes) { if (!q.loaded) continue; if (q.up) up++; else down++; } - setStatus((n >= quotes.size() ? "" : t("部分失败 · ", "partial · ")) - + t("已更新 ", "updated ") + n + "/" + quotes.size() + " · " + hm.format(new Date())); - tvStats.setText("▲" + up + " ▼" + down); - quoteAdapter.notifyDataSetChanged(); + if (mon) { + firstMon = false; + tvMonStatus.setText(t("已更新 ", "updated ") + n + "/" + list.size() + + " · " + hm.format(new Date())); + renderMon(); + } else { + int up = 0, down = 0; + for (Quote q : list) { if (!q.loaded) continue; if (q.up) up++; else down++; } + setStatus((n >= list.size() ? "" : t("部分失败 · ", "partial · ")) + + t("已更新 ", "updated ") + n + "/" + list.size() + " · " + hm.format(new Date())); + tvStats.setText("▲" + up + " ▼" + down); + quoteAdapter.notifyDataSetChanged(); + } }}); } - /** Yahoo chart 接口:一次返回报价元数据 */ boolean fetchYahoo(Quote q) { try { - String json = httpGet("https://query1.finance.yahoo.com/v8/finance/chart/" - + URLEncoder.encode(q.yahoo, "UTF-8") + "?range=1d&interval=5m"); - JSONObject meta = new JSONObject(json) - .getJSONObject("chart").getJSONArray("result").getJSONObject(0) - .getJSONObject("meta"); + JSONObject meta = chartJson(q.yahoo, "1d", "5m").getJSONObject("chart") + .getJSONArray("result").getJSONObject(0).getJSONObject("meta"); double price = meta.optDouble("regularMarketPrice", Double.NaN); double prev = meta.optDouble("chartPreviousClose", meta.optDouble("previousClose", Double.NaN)); @@ -562,7 +749,66 @@ public class MainActivity extends Activity implements View.OnClickListener { } catch (Exception e) { return false; } } - /** Stooq 兜底(指数/美股) */ + JSONObject chartJson(String sym, String range, String interval) throws Exception { + return new JSONObject(httpGet("https://query1.finance.yahoo.com/v8/finance/chart/" + + URLEncoder.encode(sym, "UTF-8") + "?range=" + range + "&interval=" + interval)); + } + + /* ── 东方财富(国内网络主力源:美股/指数/外汇) ── */ + + boolean fetchEastMoney(Quote q) { + if (q.emSecid.isEmpty()) return false; + // 美股先试 105,失败试 106 + if (fetchEmSecid(q, q.emSecid)) return true; + if (q.emSecid.startsWith("105.")) { + String alt = "106." + q.emSecid.substring(4); + if (fetchEmSecid(q, alt)) { q.emSecid = alt; return true; } + } + return false; + } + + boolean fetchEmSecid(Quote q, String secid) { + try { + String body = httpGet("https://push2.eastmoney.com/api/qt/stock/get?secid=" + + secid + "&invt=2", "https://quote.eastmoney.com/"); + JSONObject d = new JSONObject(body).optJSONObject("data"); + if (d == null) return false; + double raw = d.optDouble("f43", Double.NaN); + if (Double.isNaN(raw) || raw == 0) return false; + boolean fx = secid.startsWith("119.") || secid.startsWith("133."); + double div = fx ? 10000.0 : 100.0; + double price = raw / div; + double prev = d.optDouble("f60", Double.NaN) / div; + String nm = d.optString("f58", ""); + if (!nm.isEmpty()) q.name = nm; + applyQuote(q, price, prev, "", "EM", + d.optDouble("f44", Double.NaN) / div, + d.optDouble("f45", Double.NaN) / div, + d.optDouble("f47", Double.NaN)); + return true; + } catch (Exception e) { return false; } + } + + /* ── 新浪期货(大宗商品国内源) ── */ + boolean fetchSinaHf(Quote q) { + String code = sinaHfFor(q.yahoo); + if (code.isEmpty()) return false; + try { + String body = httpGet("https://hq.sinajs.cn/list=" + code, + "https://finance.sina.com.cn"); + int s = body.indexOf('"'), e = body.lastIndexOf('"'); + if (s < 0 || e <= s) return false; + String[] f = body.substring(s + 1, e).split(","); + if (f.length < 9) return false; + double price = Double.parseDouble(f[0].trim()); + double prev = Double.parseDouble(f[7].trim()); // 昨结 + if (price == 0) return false; + applyQuote(q, price, prev, "USD", "SINA", + Double.parseDouble(f[5].trim()), Double.parseDouble(f[6].trim()), 0); + return true; + } catch (Exception ex) { return false; } + } + boolean fetchStooq(Quote q) { try { String s = q.yahoo.startsWith("^") ? stooqIndex(q.yahoo) @@ -588,7 +834,6 @@ public class MainActivity extends Activity implements View.OnClickListener { return null; } - /** 腾讯源(国内网络回落) */ boolean fetchTencent(Quote q) { try { String body = httpGet("https://qt.gtimg.cn/q=" + q.tencent); @@ -633,13 +878,82 @@ public class MainActivity extends Activity implements View.OnClickListener { return String.format(Locale.US, "%.0f", v); } + /* ══════════ MON 渲染 ══════════ */ + + void renderMon() { + monGrid.removeAllViews(); + String[] groups = {"IDX", "CMD", "RATES", "FX", "CRYPTO"}; + String[] gnames = zh() + ? new String[]{"股票指数", "大宗商品", "债息 %", "外汇", "加密货币"} + : new String[]{"EQUITY INDICES", "COMMODITIES", "RATES %", "FX", "CRYPTO"}; + for (int g = 0; g < groups.length; g++) { + TextView gt = mono(gnames[g], 12, ORANGE, Typeface.BOLD); + gt.setPadding(dp(12), dp(8), dp(12), dp(2)); + monGrid.addView(gt, matchWrap()); + // 两列卡片 + LinearLayout row = null; + int col = 0; + for (final Quote q : monQuotes) { + if (!inGroup(groups[g], q)) continue; + if (col == 0) { + row = new LinearLayout(this); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(dp(8), 0, dp(8), 0); + monGrid.addView(row, matchWrap()); + } + row.addView(monCard(q), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + col = (col + 1) % 2; + } + if (col == 1 && row != null) row.addView(new View(this), + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + } + } + + boolean inGroup(String g, Quote q) { + for (String[] m : MON) if (m[1].equals(q.yahoo) && m[0].equals(g)) return true; + return false; + } + + View monCard(final Quote q) { + LinearLayout card = new LinearLayout(this); + card.setOrientation(LinearLayout.VERTICAL); + card.setBackgroundColor(0xFF181818); + card.setPadding(dp(10), dp(8), dp(10), dp(8)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.setMargins(dp(2), dp(2), dp(2), dp(2)); + card.setLayoutParams(lp); + + LinearLayout top = new LinearLayout(this); + top.setOrientation(LinearLayout.HORIZONTAL); + top.addView(mono(q.name, 11, GREY, Typeface.BOLD), + new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1)); + TextView pct = mono(q.loaded ? q.pct : "--", 11, + q.loaded ? (q.up ? RED : GREEN) : GREY, Typeface.BOLD); + top.addView(pct, wrapWrap()); + card.addView(top, matchWrap()); + + TextView pr = mono(q.loaded ? q.price : "--", 16, + q.loaded ? WHITE : GREY, Typeface.BOLD); + card.addView(pr, matchWrap()); + + card.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { + openDetail(q.yahoo); + }}); + return card; + } + /* ══════════ 详情 ══════════ */ void openDetail(String yahooSym) { - for (Quote q : quotes) { - if (q.yahoo.equals(yahooSym)) { detail = q; break; } + Quote found = null; + for (Quote q : quotes) if (q.yahoo.equals(yahooSym)) { found = q; break; } + if (found == null) for (Quote q : monQuotes) if (q.yahoo.equals(yahooSym)) { found = q; break; } + if (found == null) { + found = new Quote(yahooSym, null, yahooSym); + monQuotes.add(found); // 临时 } - if (detail == null) detail = new Quote(yahooSym, null, yahooSym); + detail = found; showPage(PAGE_DETAIL); renderDetail(); net.submit(new Runnable() { @Override public void run() { fetchDetail(); }}); @@ -657,16 +971,29 @@ public class MainActivity extends Activity implements View.OnClickListener { dH.setText(detail.highS); dL.setText(detail.lowS); dP.setText(Double.isNaN(detail.prevNum) ? "--" : fmt(detail.prevNum)); dV.setText(detail.volS); - LinearLayout r3 = (LinearLayout) dUpdated.getParent(); + dHi52.setText(detail.hi52S); dLo52.setText(detail.lo52S); dUpdated.setText((detail.exchange.isEmpty() ? "" : detail.exchange + " · ") + t("更新 ", "updated ") + hm.format(new Date())); } + String rangeFor(String r) { + if ("1D".equals(r)) return "1d"; + if ("5D".equals(r)) return "5d"; + if ("1M".equals(r)) return "1mo"; + if ("6M".equals(r)) return "6mo"; + return "1y"; + } + + String intervalFor(String r) { + if ("1D".equals(r)) return "5m"; + if ("5D".equals(r)) return "30m"; + return "1d"; + } + void fetchDetail() { + // ① 报价 + 当前周期K线 try { - String json = httpGet("https://query1.finance.yahoo.com/v8/finance/chart/" - + URLEncoder.encode(detail.yahoo, "UTF-8") + "?range=1d&interval=5m"); - JSONObject result = new JSONObject(json) + JSONObject result = chartJson(detail.yahoo, rangeFor(detailRange), intervalFor(detailRange)) .getJSONObject("chart").getJSONArray("result").getJSONObject(0); JSONObject meta = result.getJSONObject("meta"); double price = meta.optDouble("regularMarketPrice", Double.NaN); @@ -678,32 +1005,145 @@ public class MainActivity extends Activity implements View.OnClickListener { meta.optDouble("regularMarketDayLow", Double.NaN), meta.optDouble("regularMarketVolume", Double.NaN)); - final List pts = new ArrayList<>(); - JSONObject quote = result.getJSONObject("indicators").getJSONArray("quote").getJSONObject(0); - org.json.JSONArray ts = result.optJSONArray("timestamp"); - org.json.JSONArray cl = quote.optJSONArray("close"); - double lastKnown = Double.NaN; - if (ts != null && cl != null) { - for (int i = 0; i < ts.length() && i < cl.length(); i++) { - double v = cl.optDouble(i, Double.NaN); - if (Double.isNaN(v)) continue; - MPoint mp = new MPoint(); - mp.t = ts.getLong(i); - mp.price = v; - pts.add(mp); - lastKnown = v; - } - } - final double fprev = Double.isNaN(prev) ? lastKnown : prev; + final List cs = parseCandles(result); + final double prevF = Double.isNaN(prev) ? (cs.isEmpty() ? Double.NaN + : cs.get(cs.size() - 1).c) : prev; ui.post(new Runnable() { @Override public void run() { - chart.setData(pts, fprev); + chart.setData(cs, prevF, "1D".equals(detailRange)); renderDetail(); quoteAdapter.notifyDataSetChanged(); }}); - } catch (Exception ignored) { } + } catch (Exception ignored) { fetchDetailEm(); } + + // ② 52 周高低(仅在缺失时算一次) + if (detail.hi52S.equals("--")) { + try { + JSONObject result = chartJson(detail.yahoo, "1y", "1d") + .getJSONObject("chart").getJSONArray("result").getJSONObject(0); + double hi = -Double.MAX_VALUE, lo = Double.MAX_VALUE; + JSONArray cl = result.getJSONObject("indicators").getJSONArray("quote") + .getJSONObject(0).optJSONArray("close"); + if (cl != null) { + for (int i = 0; i < cl.length(); i++) { + double v = cl.optDouble(i, Double.NaN); + if (Double.isNaN(v)) continue; + hi = Math.max(hi, v); lo = Math.min(lo, v); + } + } + if (hi > lo) { + final String hs = fmt(hi), ls = fmt(lo); + ui.post(new Runnable() { @Override public void run() { + detail.hi52S = hs; detail.lo52S = ls; + renderDetail(); + }}); + } + } catch (Exception ignored) { } + } fetchRss("https://finance.yahoo.com/rss/2.0/headline?s=" + detail.yahoo, detailNews, detailNewsAdapter); } + /* ── 国内网络:东财报价 + K 线回落 ── */ + void fetchDetailEm() { + boolean got = fetchEastMoney(detail) || (detail.tencent != null && fetchTencent(detail)) + || fetchSinaHf(detail); + if (got) ui.post(new Runnable() { @Override public void run() { + renderDetail(); + quoteAdapter.notifyDataSetChanged(); + }}); + // 东财K线 + String secid = detail.emSecid; + if (secid.isEmpty() || secid.startsWith("105.")) { + // 106 兜底交给 fetchEmKline 内部尝试 + } + List cs = fetchEmKline(secid); + if (cs.isEmpty() && secid.startsWith("105.")) + cs = fetchEmKline("106." + secid.substring(4)); + if (!cs.isEmpty()) { + final List fcs = cs; + final double prevF = detail.prevNum; + ui.post(new Runnable() { @Override public void run() { + chart.setData(fcs, prevF, "1D".equals(detailRange)); + }}); + // 52 周从日线算 + if (detail.hi52S.equals("--") && "1Y".equals(detailRange)) { + double hi = -Double.MAX_VALUE, lo = Double.MAX_VALUE; + for (Candle c : cs) { hi = Math.max(hi, c.h); lo = Math.min(lo, c.l); } + if (hi > lo) { + final String hs = fmt(hi), ls = fmt(lo); + ui.post(new Runnable() { @Override public void run() { + detail.hi52S = hs; detail.lo52S = ls; + renderDetail(); + }}); + } + } + } + } + + List fetchEmKline(String secid) { + List out = new ArrayList<>(); + if (secid.isEmpty()) return out; + try { + String klt, lmt; + if ("1D".equals(detailRange)) { klt = "5"; lmt = "48"; } + else if ("5D".equals(detailRange)) { klt = "30"; lmt = "70"; } + else if ("1M".equals(detailRange)) { klt = "101"; lmt = "22"; } + else if ("6M".equals(detailRange)) { klt = "101"; lmt = "130"; } + else { klt = "101"; lmt = "250"; } + String json = httpGet("https://push2his.eastmoney.com/api/qt/stock/kline/get?secid=" + + secid + "&klt=" + klt + "&fqt=1&fields1=f1,f2,f3" + + "&fields2=f51,f52,f53,f54,f55,f56,f57&end=20500101&lmt=" + lmt, + "https://quote.eastmoney.com/"); + JSONObject d = new JSONObject(json).optJSONObject("data"); + if (d == null) return out; + JSONArray kl = d.optJSONArray("klines"); + if (kl == null) return out; + boolean intraday = "5".equals(klt) || "30".equals(klt); + SimpleDateFormat df = intraday + ? new SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) + : new SimpleDateFormat("yyyy-MM-dd", Locale.US); + for (int i = 0; i < kl.length(); i++) { + String[] f = kl.getString(i).split(","); + if (f.length < 6) continue; + Candle c = new Candle(); + c.o = Double.parseDouble(f[1]); + c.c = Double.parseDouble(f[2]); + c.h = Double.parseDouble(f[3]); + c.l = Double.parseDouble(f[4]); + c.v = Double.parseDouble(f[5]); + try { c.t = df.parse(f[0]).getTime() / 1000.0; } catch (Exception e) { c.t = i; } + out.add(c); + } + } catch (Exception ignored) { } + return out; + } + + List parseCandles(JSONObject result) { + List out = new ArrayList<>(); + try { + JSONArray ts = result.optJSONArray("timestamp"); + JSONObject q = result.getJSONObject("indicators").getJSONArray("quote").getJSONObject(0); + if (ts == null) return out; + JSONArray o = q.optJSONArray("open"), h = q.optJSONArray("high"), + l = q.optJSONArray("low"), c = q.optJSONArray("close"), + v = q.optJSONArray("volume"); + for (int i = 0; i < ts.length(); i++) { + double cv = c == null ? Double.NaN : c.optDouble(i, Double.NaN); + if (Double.isNaN(cv)) continue; + Candle cd = new Candle(); + cd.t = ts.getLong(i); + cd.c = cv; + cd.o = o == null ? cv : orDefault(o.optDouble(i, Double.NaN), cv); + cd.h = h == null ? cv : orDefault(h.optDouble(i, Double.NaN), Math.max(cd.o, cv)); + cd.l = l == null ? cv : orDefault(l.optDouble(i, Double.NaN), Math.min(cd.o, cv)); + cd.v = v == null ? 0 : orDefault(v.optDouble(i, Double.NaN), 0); + out.add(cd); + } + } catch (Exception ignored) { } + return out; + } + + static double orDefault(double v, double d) { return Double.isNaN(v) ? d : v; } + /* ══════════ 搜索 ══════════ */ void doSearch(final String q) { @@ -712,7 +1152,7 @@ public class MainActivity extends Activity implements View.OnClickListener { String json = httpGet("https://query1.finance.yahoo.com/v1/finance/search?q=" + URLEncoder.encode(q, "UTF-8") + ""esCount=15&newsCount=0"); final List out = new ArrayList<>(); - org.json.JSONArray arr = new JSONObject(json).optJSONArray("quotes"); + JSONArray arr = new JSONObject(json).optJSONArray("quotes"); if (arr != null) { for (int i = 0; i < arr.length(); i++) { JSONObject o = arr.getJSONObject(i); @@ -764,37 +1204,72 @@ public class MainActivity extends Activity implements View.OnClickListener { /* ══════════ 资讯 RSS ══════════ */ void fetchNews() { - fetchRss("https://finance.yahoo.com/news/rssindex", news, newsAdapter); + net.submit(new Runnable() { @Override public void run() { + boolean ok = fetchRssSync("https://finance.yahoo.com/news/rssindex", news); + if (!ok) fetchSinaNews(news); + }}); + } + + /** 新浪全球财经快讯(国内网络回落源) */ + void fetchSinaNews(final List sink) { + try { + String json = httpGet("https://zhibo.sina.com.cn/api/zhibo/feed?page=1&page_size=30&zhibo_id=152&tag_id=0&dire=f&dpc=1"); + org.json.JSONArray list = new JSONObject(json) + .getJSONObject("result").getJSONObject("data") + .getJSONObject("feed").getJSONArray("list"); + final List out = new ArrayList<>(); + for (int i = 0; i < list.length() && out.size() < 30; i++) { + JSONObject o = list.getJSONObject(i); + NewsItem n = new NewsItem(); + n.title = o.optString("rich_text", ""); + String ct = o.optString("create_time", ""); + n.time = ct.length() >= 5 ? ct : ""; + if (!n.title.isEmpty()) out.add(n); + } + ui.post(new Runnable() { @Override public void run() { + if (out.isEmpty()) return; + sink.clear(); sink.addAll(out); + newsAdapter.notifyDataSetChanged(); + }}); + } catch (Exception ignored) { } } void fetchRss(final String feed, final List sink, final NewsAdapter adapter) { net.submit(new Runnable() { @Override public void run() { - try { - String xml = httpGet(feed); - final List out = new ArrayList<>(); - int i = 0; - while (out.size() < 30) { - int s = xml.indexOf("", i); - if (s < 0) break; - int e = xml.indexOf("", s); - if (e < 0) break; - String seg = xml.substring(s, e); - NewsItem n = new NewsItem(); - n.title = tag(seg, "title"); - n.time = tag(seg, "pubDate"); - n.link = tag(seg, "link"); - if (!n.title.isEmpty()) out.add(n); - i = e + 7; - } - ui.post(new Runnable() { @Override public void run() { - if (out.isEmpty()) return; - sink.clear(); sink.addAll(out); - adapter.notifyDataSetChanged(); - }}); - } catch (Exception ignored) { } + boolean ok = fetchRssSync(feed, sink); + // 国内网络:Yahoo RSS 不通时回落新浪快讯 + if (!ok && sink == detailNews) fetchSinaNews(sink); }}); } + boolean fetchRssSync(String feed, List sink) { + try { + String xml = httpGet(feed); + final List out = new ArrayList<>(); + int i = 0; + while (out.size() < 30) { + int s = xml.indexOf("", i); + if (s < 0) break; + int e = xml.indexOf("", s); + if (e < 0) break; + String seg = xml.substring(s, e); + NewsItem n = new NewsItem(); + n.title = tag(seg, "title"); + n.time = tag(seg, "pubDate"); + n.link = tag(seg, "link"); + if (!n.title.isEmpty()) out.add(n); + i = e + 7; + } + if (out.isEmpty()) return false; + ui.post(new Runnable() { @Override public void run() { + sink.clear(); sink.addAll(out); + newsAdapter.notifyDataSetChanged(); + detailNewsAdapter.notifyDataSetChanged(); + }}); + return true; + } catch (Exception e) { return false; } + } + static String tag(String xml, String name) { int s = xml.indexOf("<" + name + ">"); if (s < 0) return ""; @@ -932,17 +1407,20 @@ public class MainActivity extends Activity implements View.OnClickListener { } } - /* ══════════ 分时图 ══════════ */ + /* ══════════ K 线/分时图 ══════════ */ - class MinuteChart extends View { - List data = new ArrayList<>(); + class CandleChart extends View { + List data = new ArrayList<>(); double prev = Double.NaN; + boolean lineMode = true; final Paint pLine = new Paint(), pFill = new Paint(), pGrid = new Paint(), - pBase = new Paint(), pText = new Paint(), pLast = new Paint(); - double lo = 0, hi = 1; + pBase = new Paint(), pText = new Paint(), pLast = new Paint(), + pUp = new Paint(), pDn = new Paint(), pUpW = new Paint(), pDnW = new Paint(), + pVolUp = new Paint(), pVolDn = new Paint(); + double lo = 0, hi = 1, maxVol = 1; double loT = 0, hiT = 1; - MinuteChart(Context c) { + CandleChart(Context c) { super(c); pLine.setColor(ORANGE); pLine.setStyle(Paint.Style.STROKE); pLine.setStrokeWidth(dpx(1.5f)); pFill.setColor(0x33FF9900); pFill.setStyle(Paint.Style.FILL); @@ -952,59 +1430,105 @@ public class MainActivity extends Activity implements View.OnClickListener { pText.setColor(GREY); pText.setTextSize(dp(9)); pText.setTypeface(Typeface.MONOSPACE); pLast.setTextSize(dp(10)); pLast.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)); + pUp.setColor(RED); pDn.setColor(GREEN); + pUpW.setStyle(Paint.Style.STROKE); pUpW.setColor(RED); pUpW.setStrokeWidth(1); + pDnW.setStyle(Paint.Style.STROKE); pDnW.setColor(GREEN); pDnW.setStrokeWidth(1); + pVolUp.setColor(0x80E53935); pVolDn.setColor(0x8000C853); } - void setData(List pts, double prevClose) { - data = pts; prev = prevClose; - if (pts.isEmpty()) { invalidate(); return; } - lo = Double.MAX_VALUE; hi = -Double.MAX_VALUE; - for (MPoint p : pts) { lo = Math.min(lo, p.price); hi = Math.max(hi, p.price); } + void setData(List cs, double prevClose, boolean line) { + data = cs; prev = prevClose; lineMode = line; + if (cs.isEmpty()) { invalidate(); return; } + lo = Double.MAX_VALUE; hi = -Double.MAX_VALUE; maxVol = 1; + for (Candle p : cs) { + lo = Math.min(lo, p.l); hi = Math.max(hi, p.h); + maxVol = Math.max(maxVol, p.v); + } if (!Double.isNaN(prevClose)) { lo = Math.min(lo, prevClose); hi = Math.max(hi, prevClose); } if (hi - lo < 1e-9) { hi += 1; lo -= 1; } - double pad = (hi - lo) * 0.08; hi += pad; lo -= pad; - loT = pts.get(0).t; hiT = pts.get(pts.size() - 1).t; + double pad = (hi - lo) * 0.06; hi += pad; lo -= pad; + loT = cs.get(0).t; hiT = cs.get(cs.size() - 1).t; if (hiT <= loT) hiT = loT + 1; invalidate(); } @Override protected void onDraw(Canvas cv) { int w = getWidth(), h = getHeight(); - int pl = dp(8), pr = dp(60), pt = dp(14), pb = dp(16); + int pl = dp(6), pr = dp(58), pt = dp(12), pb = dp(14); + float volH = (h - pt - pb) * 0.2f; // 成交量副图高 + float priceH = (h - pt - pb) - volH - dp(6); cv.drawColor(BG); for (int i = 0; i <= 4; i++) { - float y = pt + (h - pt - pb) * i / 4f; + float y = pt + priceH * i / 4f; cv.drawLine(pl, y, w - pr, y, pGrid); double v = hi - (hi - lo) * i / 4.0; cv.drawText(fmt(v), w - pr + dp(4), y + dp(3), pText); } if (data.isEmpty()) { - cv.drawText(t("暂无分时数据", "No intraday data"), pl, h / 2f, pText); + cv.drawText(t("暂无数据", "No data"), pl, h / 2f, pText); return; } if (!Double.isNaN(prev)) { - float y = (float) (pt + (h - pt - pb) * (1 - (prev - lo) / (hi - lo))); + float y = (float) (pt + priceH * (1 - (prev - lo) / (hi - lo))); cv.drawLine(pl, y, w - pr, y, pBase); } - Path line = new Path(), fill = new Path(); - boolean first = true; - for (MPoint p : data) { - float x = (float) (pl + (w - pl - pr) * (p.t - loT) / (hiT - loT)); - float y = (float) (pt + (h - pt - pb) * (1 - (p.price - lo) / (hi - lo))); - if (first) { line.moveTo(x, y); fill.moveTo(x, h - pb); fill.lineTo(x, y); first = false; } - else { line.lineTo(x, y); fill.lineTo(x, y); } + double span = hiT - loT; + int n = data.size(); + float cw = Math.max(dpx(2f), (w - pl - pr) / (float) Math.max(n, 1)); // 蜡烛宽 + SimpleDateFormat tf = new SimpleDateFormat("MM/dd", Locale.US); + if (lineMode) { + Path line = new Path(), fill = new Path(); + boolean first = true; + float lx = 0, ly = 0; + for (Candle p : data) { + float x = (float) (pl + (w - pl - pr) * (p.t - loT) / span); + float y = (float) (pt + priceH * (1 - (p.c - lo) / (hi - lo))); + if (first) { line.moveTo(x, y); fill.moveTo(x, pt + priceH); fill.lineTo(x, y); first = false; } + else { line.lineTo(x, y); fill.lineTo(x, y); } + lx = x; ly = y; + } + fill.lineTo(w - pr, pt + priceH); fill.close(); + cv.drawPath(fill, pFill); + cv.drawPath(line, pLine); + Candle lastC = data.get(n - 1); + pLast.setColor(lastC.c >= prev ? RED : GREEN); + cv.drawCircle(lx, ly, dpx(2.5f), pLast); + String ls = fmt(lastC.c); + cv.drawText(ls, Math.min(lx + dp(4), w - pLast.measureText(ls) - dp(2)), + Math.max(ly - dp(6), pt + dp(8)), pLast); + } else { + for (int i = 0; i < n; i++) { + Candle p = data.get(i); + float x = pl + cw * i + cw / 2f; + boolean up = p.c >= p.o; + float yO = (float) (pt + priceH * (1 - (p.o - lo) / (hi - lo))); + float yC = (float) (pt + priceH * (1 - (p.c - lo) / (hi - lo))); + float yH = (float) (pt + priceH * (1 - (p.h - lo) / (hi - lo))); + float yL = (float) (pt + priceH * (1 - (p.l - lo) / (hi - lo))); + Paint wick = up ? pUpW : pDnW; + cv.drawLine(x, yH, x, yL, wick); + Paint body = up ? pUp : pDn; + float top = Math.min(yO, yC), bot = Math.max(yO, yC); + if (bot - top < dpx(1f)) bot = top + dpx(1f); + cv.drawRect(x - cw * 0.35f, top, x + cw * 0.35f, bot, body); + } } - MPoint lastP = data.get(data.size() - 1); - fill.lineTo(w - pr, h - pb); fill.close(); - cv.drawPath(fill, pFill); - cv.drawPath(line, pLine); - float lx = (float) (pl + (w - pl - pr) * (lastP.t - loT) / (hiT - loT)); - float ly = (float) (pt + (h - pt - pb) * (1 - (lastP.price - lo) / (hi - lo))); - pLast.setColor(lastP.price >= prev ? RED : GREEN); - cv.drawCircle(lx, ly, dpx(2.5f), pLast); - String ls = fmt(lastP.price); - cv.drawText(ls, Math.min(lx + dp(4), w - pLast.measureText(ls) - dp(2)), - Math.max(ly - dp(6), pt + dp(8)), pLast); - SimpleDateFormat tf = new SimpleDateFormat("HH:mm", Locale.US); + // 成交量副图 + float vy = pt + priceH + dp(6); + for (int i = 0; i < n; i++) { + Candle p = data.get(i); + if (maxVol <= 0) break; + float vh = (float) (volH * p.v / maxVol); + float x0, x1; + if (lineMode) { + x0 = pl + cw * i; x1 = pl + cw * (i + 1); + } else { + x0 = pl + cw * i + cw * 0.3f; x1 = pl + cw * (i + 1) - cw * 0.3f; + } + Paint vp = p.c >= p.o ? pVolUp : pVolDn; + cv.drawRect(x0, vy + volH - vh, x1, vy + volH, vp); + } + // 时间轴 cv.drawText(tf.format(new Date((long) (loT * 1000L))), pl, h - dp(4), pText); String rs = tf.format(new Date((long) (hiT * 1000L))); cv.drawText(rs, w - pr - pText.measureText(rs), h - dp(4), pText);